fix(gateway): refresh CAM on client joins

The gateway already emits periodic CAM refresh frames so the physical switch
keeps remote client MACs on the gateway port. A newly joined client previously
waited until either its own tunneled traffic reached the LAN or the first
60-second refresh tick fired.

Emit one padded CAM refresh frame immediately when a valid client PeerJoined
control event is observed. This makes the switch MAC-table check in the MVP
procedure visible sooner and keeps the periodic refresh as the aging guard.
The refresh uses the same maintenance frame shape as the periodic path and is
logged with the client peer id and MAC.

README.md and TESTING.md now document the immediate refresh behavior and the
log signal to look for during manual LAN testing.

Test Plan:
- cargo test -p lanparty-gateway
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- cargo fmt --check
- git diff --check

Refs: PLAN.md CAM refresh; TESTING.md MVP switch MAC-table check
This commit is contained in:
2026-05-22 06:01:54 +02:00
parent 92daa1d2ae
commit 566f2d43a8
3 changed files with 98 additions and 12 deletions
+2 -1
View File
@@ -195,7 +195,8 @@ wired interfaces whose sysfs carrier state reports no link; managed wireless
NICs are not supported for the physical LAN bridge. NICs are not supported for the physical LAN bridge.
It tracks remote-client MACs from relay lifecycle events and periodically emits It tracks remote-client MACs from relay lifecycle events and periodically emits
small CAM refresh frames so the physical switch keeps those MACs associated small CAM refresh frames so the physical switch keeps those MACs associated
with the gateway port. Gateway with the gateway port. A newly observed client also triggers an immediate CAM
refresh frame instead of waiting for the first periodic refresh tick. Gateway
frame logs include direction, peer id when present, MACs, ethertype/length, frame logs include direction, peer id when present, MACs, ethertype/length,
frame length, action, and drop reason. The gateway also tracks frame/datagram frame length, action, and drop reason. The gateway also tracks frame/datagram
counters and periodically sends stats snapshots to the relay. Malformed or runt counters and periodically sends stats snapshots to the relay. Malformed or runt
+1
View File
@@ -190,6 +190,7 @@ Gateway LAN traffic:
```text ```text
gateway frame interface=eth0 direction=LanToRemote ... action=Forwarded gateway frame interface=eth0 direction=LanToRemote ... action=Forwarded
gateway frame interface=eth0 direction=RemoteToLan ... action=Forwarded gateway frame interface=eth0 direction=RemoteToLan ... action=Forwarded
gateway CAM refresh interface=eth0 peer_id=... mac=... reason=peer_joined
``` ```
Client health: Client health:
+92 -8
View File
@@ -435,8 +435,20 @@ impl GatewayConnection {
let control_stream = control_stream let control_stream = control_stream
.context("failed to accept gateway control event stream")?; .context("failed to accept gateway control event stream")?;
let control_event = read_gateway_control_event(control_stream).await?; let control_event = read_gateway_control_event(control_stream).await?;
remote_clients.observe_control_event(&control_event); let immediate_refresh = remote_clients.observe_control_event(&control_event);
println!("{}", format_gateway_control_event(&control_event)); println!("{}", format_gateway_control_event(&control_event));
if let Some(refresh) = immediate_refresh {
write_lan_ethernet(&packet_socket, refresh.frame()).await?;
println!(
"{}",
gateway_cam_refresh_log_line(
packet_socket.get_ref().interface(),
refresh.peer_id(),
refresh.mac(),
"peer_joined",
)
);
}
} }
} }
} }
@@ -760,6 +772,16 @@ fn gateway_frame_log_line(
) )
} }
#[cfg(target_os = "linux")]
fn gateway_cam_refresh_log_line(
interface: &str,
peer_id: u32,
mac: MacAddr,
reason: &str,
) -> String {
format!("gateway CAM refresh interface={interface} peer_id={peer_id} mac={mac} reason={reason}")
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
async fn read_lan_ethernet(packet_socket: &AsyncFd<PacketSocket>) -> Result<Bytes> { async fn read_lan_ethernet(packet_socket: &AsyncFd<PacketSocket>) -> Result<Bytes> {
loop { loop {
@@ -803,6 +825,37 @@ struct RemoteClientTable {
remote_clients: BTreeMap<u32, MacAddr>, remote_clients: BTreeMap<u32, MacAddr>,
} }
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, PartialEq, Eq)]
struct CamRefresh {
peer_id: u32,
mac: MacAddr,
frame: Vec<u8>,
}
#[cfg(target_os = "linux")]
impl CamRefresh {
fn new(peer_id: u32, mac: MacAddr, gateway_mac: MacAddr) -> Self {
Self {
peer_id,
mac,
frame: cam_refresh_frame(mac, gateway_mac),
}
}
const fn peer_id(&self) -> u32 {
self.peer_id
}
const fn mac(&self) -> MacAddr {
self.mac
}
fn frame(&self) -> &[u8] {
&self.frame
}
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
impl RemoteClientTable { impl RemoteClientTable {
fn new(gateway_mac: MacAddr) -> Self { fn new(gateway_mac: MacAddr) -> Self {
@@ -824,21 +877,27 @@ impl RemoteClientTable {
Ok(None) Ok(None)
} }
fn observe_control_event(&mut self, event: &ControlMessage) { fn observe_control_event(&mut self, event: &ControlMessage) -> Option<CamRefresh> {
match event { match event {
ControlMessage::PeerJoined(peer) => self.observe_peer_joined(peer), ControlMessage::PeerJoined(peer) => self.observe_peer_joined(peer),
ControlMessage::PeerLeft { peer_id, .. } => self.observe_peer_left(*peer_id), ControlMessage::PeerLeft { peer_id, .. } => {
_ => {} self.observe_peer_left(*peer_id);
None
}
_ => None,
} }
} }
fn observe_peer_joined(&mut self, peer: &PeerInfo) { fn observe_peer_joined(&mut self, peer: &PeerInfo) -> Option<CamRefresh> {
if peer.role() == Role::Client if peer.role() == Role::Client
&& let Some(mac) = peer.mac() && let Some(mac) = peer.mac()
&& mac.is_valid_client_identity() && mac.is_valid_client_identity()
{ {
self.remote_clients.insert(peer.peer_id(), mac); self.remote_clients.insert(peer.peer_id(), mac);
return Some(CamRefresh::new(peer.peer_id(), mac, self.gateway_mac));
} }
None
} }
fn observe_peer_left(&mut self, peer_id: u32) { fn observe_peer_left(&mut self, peer_id: u32) {
@@ -1243,9 +1302,10 @@ mod tests {
Some(DropReason::UnauthorizedSourceMac) Some(DropReason::UnauthorizedSourceMac)
); );
remote_clients.observe_control_event(&ControlMessage::PeerJoined( let refresh = remote_clients.observe_control_event(&ControlMessage::PeerJoined(
PeerInfo::new(7, Role::Client, Some(remote_mac)).unwrap(), PeerInfo::new(7, Role::Client, Some(remote_mac)).unwrap(),
)); ));
assert!(refresh.is_some());
assert_eq!( assert_eq!(
remote_clients remote_clients
@@ -1274,9 +1334,16 @@ mod tests {
let remote_mac = MacAddr::new([0x02, 0, 0, 0, 0, 2]); let remote_mac = MacAddr::new([0x02, 0, 0, 0, 0, 2]);
let mut refresh = RemoteClientTable::new(gateway_mac); let mut refresh = RemoteClientTable::new(gateway_mac);
refresh.observe_control_event(&ControlMessage::PeerJoined( let immediate_refresh = refresh.observe_control_event(&ControlMessage::PeerJoined(
PeerInfo::new(7, Role::Client, Some(remote_mac)).unwrap(), PeerInfo::new(7, Role::Client, Some(remote_mac)).unwrap(),
)); ));
let immediate_refresh = immediate_refresh.expect("client joins trigger CAM refresh");
let immediate_frame = EthernetFrame::parse(immediate_refresh.frame()).unwrap();
assert_eq!(immediate_refresh.peer_id(), 7);
assert_eq!(immediate_refresh.mac(), remote_mac);
assert_eq!(immediate_frame.source(), remote_mac);
assert_eq!(immediate_frame.destination(), gateway_mac);
assert_eq!(refresh.remote_mac_count(), 1); assert_eq!(refresh.remote_mac_count(), 1);
assert_eq!( assert_eq!(
EthernetFrame::parse(&refresh.refresh_frames()[0]) EthernetFrame::parse(&refresh.refresh_frames()[0])
@@ -1285,10 +1352,13 @@ mod tests {
remote_mac remote_mac
); );
assert_eq!(
refresh.observe_control_event(&ControlMessage::PeerLeft { refresh.observe_control_event(&ControlMessage::PeerLeft {
peer_id: 7, peer_id: 7,
reason: DisconnectReason::Normal, reason: DisconnectReason::Normal,
}); }),
None
);
assert_eq!(refresh.remote_mac_count(), 0); assert_eq!(refresh.remote_mac_count(), 0);
assert!(refresh.refresh_frames().is_empty()); assert!(refresh.refresh_frames().is_empty());
} }
@@ -1311,6 +1381,20 @@ mod tests {
); );
} }
#[cfg(target_os = "linux")]
#[test]
fn formats_gateway_cam_refresh_log_line() {
assert_eq!(
gateway_cam_refresh_log_line(
"eth0",
7,
MacAddr::new([0x02, 0, 0, 0, 0, 2]),
"peer_joined"
),
"gateway CAM refresh interface=eth0 peer_id=7 mac=02:00:00:00:00:02 reason=peer_joined"
);
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[test] #[test]
fn formats_gateway_datagram_budget_drops() { fn formats_gateway_datagram_budget_drops() {