feat(gateway): refresh remote MAC learning
The LAN switch has to keep learning that remote client MAC addresses live on the gateway port. Once the gateway injects a remote client's traffic, that learning can age out if the client is quiet, breaking the Layer 2 illusion. Track valid remote-client source MACs observed in relay traffic and inject a small padded CAM refresh frame for each known MAC every 60 seconds. The refresh frame uses the remote MAC as the Ethernet source and the gateway NIC MAC as the destination, with a local experimental EtherType so hosts should ignore it. PacketSocket now reads the wired interface hardware address with SIOCGIFHWADDR when opening the AF_PACKET socket. Non-Ethernet or invalid source interfaces fail early instead of starting a gateway that cannot emit refresh traffic. Test Plan: - cargo fmt --check - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check Refs: PLAN.md Linux gateway CAM-table refresh
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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<PacketSocket>, frame: &[u8])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Debug, Clone)]
|
||||
struct CamRefresh {
|
||||
gateway_mac: MacAddr,
|
||||
remote_macs: BTreeSet<MacAddr>,
|
||||
}
|
||||
|
||||
#[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<Vec<u8>> {
|
||||
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<u8> {
|
||||
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<GatewayConnection> {
|
||||
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<u8> {
|
||||
ethernet_frame_from(MacAddr::new([0x02, 0, 0, 0, 0, 1]), payload)
|
||||
}
|
||||
|
||||
fn ethernet_frame_from(source: MacAddr, payload: &[u8]) -> Vec<u8> {
|
||||
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
|
||||
|
||||
@@ -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<usize> {
|
||||
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<u32> {
|
||||
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<CString> {
|
||||
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<u32> {
|
||||
"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<MacAddr> {
|
||||
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::<libc::ifreq>()
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user