fix(gateway): drop unrelated LAN unicast locally

The gateway runs in promiscuous mode so it can capture frames for remote client
MACs, but that also means it sees unrelated unicast traffic between physical
LAN machines. Before this change those frames were sent to the public relay and
only then discarded as unknown destinations.

Use the remote-client table seeded by relay lifecycle events to decide whether
a LAN frame should leave the gateway. Broadcast and multicast traffic still
flows to the relay, and unicast to a connected remote client still flows to the
relay. Unicast to any other destination is counted and logged locally as
UnknownDestination.

This keeps busy LAN traffic out of the relay data path and makes the gateway
behavior match the MVP switching model: LAN frames go to matching remote
clients, while broadcast and multicast fan out.

README.md documents the local filter, and TESTING.md explains why
LanToRemote UnknownDestination can be normal on a busy LAN.

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 switching model; TESTING.md MVP log signals
This commit is contained in:
2026-05-22 06:18:37 +02:00
parent 566f2d43a8
commit f4ee5b4d2c
3 changed files with 85 additions and 4 deletions
+7 -4
View File
@@ -201,10 +201,13 @@ 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
LAN frames are counted and logged as dropped instead of disappearing before
accounting. Relay lifecycle events seed and retire remote-client MACs for CAM
refresh even before that client sends traffic. On shutdown, the gateway sends a
best-effort disconnect control message before closing QUIC so the relay can
report the intended reason.
accounting. It drops unrelated LAN unicast locally once the destination is known
not to be a connected remote client, so busy LAN traffic is not sent to the
public relay just to be discarded there. Relay lifecycle events seed and retire
remote-client MACs for CAM refresh and LAN-destination filtering even before
that client sends traffic. On shutdown, the gateway sends a best-effort
disconnect control message before closing QUIC so the relay can report the
intended reason.
## Windows Client
+4
View File
@@ -208,6 +208,10 @@ drop_reason=DatagramBudget
drop_reason=RateLimit
```
On gateway `LanToRemote` logs, `UnknownDestination` usually means the gateway
captured unrelated LAN unicast and dropped it locally instead of sending it to
the relay.
Drops that should be investigated if they dominate:
```text
+74
View File
@@ -348,6 +348,21 @@ impl GatewayConnection {
);
continue;
}
if let Some(drop_reason) = remote_clients.lan_frame_drop_reason(&lan_frame)? {
stats.record_dropped_frame();
println!(
"{}",
gateway_frame_log_line(
packet_socket.get_ref().interface(),
FrameDirection::LanToRemote,
Some(welcome.peer_id()),
&lan_frame,
FrameAction::Dropped,
Some(drop_reason),
)
);
continue;
}
let outcome = send_gateway_ethernet(
&connection,
@@ -877,6 +892,17 @@ impl RemoteClientTable {
Ok(None)
}
fn lan_frame_drop_reason(&self, frame: &[u8]) -> Result<Option<DropReason>> {
let frame = EthernetFrame::parse(frame).context("LAN Ethernet frame is malformed")?;
let destination = frame.destination();
let targets_remote_client = self.remote_clients.values().any(|mac| *mac == destination);
if destination.is_multicast() || targets_remote_client {
return Ok(None);
}
Ok(Some(DropReason::UnknownDestination))
}
fn observe_control_event(&mut self, event: &ControlMessage) -> Option<CamRefresh> {
match event {
ControlMessage::PeerJoined(peer) => self.observe_peer_joined(peer),
@@ -1327,6 +1353,45 @@ mod tests {
);
}
#[cfg(target_os = "linux")]
#[test]
fn filters_unrelated_lan_unicast_before_relay_send() {
let gateway_mac = MacAddr::new([0x0a, 0, 0, 0, 0, 1]);
let remote_mac = MacAddr::new([0x02, 0, 0, 0, 0, 2]);
let lan_mac = MacAddr::new([0x0a, 0, 0, 0, 0, 3]);
let mut remote_clients = RemoteClientTable::new(gateway_mac);
assert_eq!(
remote_clients
.lan_frame_drop_reason(&broadcast_ethernet_frame(b"lan broadcast"))
.unwrap(),
None
);
assert_eq!(
remote_clients
.lan_frame_drop_reason(&ethernet_frame_to(remote_mac, b"before joined"))
.unwrap(),
Some(DropReason::UnknownDestination)
);
remote_clients.observe_control_event(&ControlMessage::PeerJoined(
PeerInfo::new(7, Role::Client, Some(remote_mac)).unwrap(),
));
assert_eq!(
remote_clients
.lan_frame_drop_reason(&ethernet_frame_to(remote_mac, b"remote unicast"))
.unwrap(),
None
);
assert_eq!(
remote_clients
.lan_frame_drop_reason(&ethernet_frame_to(lan_mac, b"lan host unicast"))
.unwrap(),
Some(DropReason::UnknownDestination)
);
}
#[cfg(target_os = "linux")]
#[test]
fn updates_cam_refresh_from_lifecycle_events() {
@@ -1462,6 +1527,15 @@ mod tests {
ethernet_frame_with_addresses(MacAddr::new([0x02, 0, 0, 0, 0, 2]), source, 0x0800, payload)
}
fn ethernet_frame_to(destination: MacAddr, payload: &[u8]) -> Vec<u8> {
ethernet_frame_with_addresses(
destination,
MacAddr::new([0x0a, 0, 0, 0, 0, 1]),
0x0800,
payload,
)
}
fn control_plane_ethernet_frame() -> Vec<u8> {
ethernet_frame_with_addresses(
MacAddr::new([0x01, 0x80, 0xc2, 0, 0, 0]),