fix(gateway): require announced MAC before LAN injection

The relay already enforces client source MAC identity before forwarding, but
this gateway bridge could still write any safety-clean relayed frame to
AF_PACKET. That left the final physical-LAN boundary depending entirely on the
relay forwarding path.

Keep a lifecycle-seeded remote client table in the gateway bridge and reject
relay frames whose datagram peer id is unknown or whose Ethernet source MAC does
not match the announced client MAC. CAM refresh now uses the same announced
table instead of learning source MACs from relay traffic.

This is conservative: if data arrives before the lifecycle event, the gateway
drops that frame with UnauthorizedSourceMac. Later packets proceed after the
control event is processed.

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

Refs: PLAN.md safety filters; TESTING.md MVP acceptance guide
This commit is contained in:
2026-05-22 05:38:35 +02:00
parent 234bece265
commit bd6479e4b5
3 changed files with 80 additions and 36 deletions
+5 -4
View File
@@ -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 control-plane traffic, jumbo frames, or encoded datagrams exceeding the
negotiated QUIC budget are counted, dropped, and logged locally instead of negotiated QUIC budget are counted, dropped, and logged locally instead of
stopping the bridge or consuming relay bandwidth. Remote frames received from 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 L2 control-plane, remote VLAN, DHCP-server, IPv6 Router Advertisement, IPv6
fragment, and jumbo frames cannot cross the gateway's final physical-LAN fragment, and jumbo frames cannot cross the gateway's final physical-LAN
boundary even if they reached the gateway over QUIC. 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 The gateway rejects Linux interfaces that sysfs identifies as Wi-Fi, and rejects
wired interfaces whose sysfs carrier state reports no link; managed wireless 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 source MACs seen from relay traffic and periodically It tracks remote-client MACs from relay lifecycle events and periodically emits
emits small CAM refresh frames so the physical switch keeps those MACs small CAM refresh frames so the physical switch keeps those MACs associated
associated with the gateway port. Gateway with the gateway port. 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
+4
View File
@@ -220,6 +220,10 @@ drop_reason=Ipv6RouterAdvertisement
drop_reason=Ipv6Fragment 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 ## Troubleshooting
If the client says `Waiting for LAN gateway`, check that the gateway uses the If the client says `Waiting for LAN gateway`, check that the gateway uses the
+71 -32
View File
@@ -291,7 +291,7 @@ impl GatewayConnection {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub async fn bridge_until_shutdown(self, packet_socket: PacketSocket) -> Result<()> { 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( let mut cam_refresh_tick = tokio::time::interval_at(
tokio::time::Instant::now() + CAM_REFRESH_INTERVAL, tokio::time::Instant::now() + CAM_REFRESH_INTERVAL,
CAM_REFRESH_INTERVAL, CAM_REFRESH_INTERVAL,
@@ -375,8 +375,24 @@ impl GatewayConnection {
relay_frame = recv_gateway_ethernet_outcome(&connection, &welcome, &stats) => { relay_frame = recv_gateway_ethernet_outcome(&connection, &welcome, &stats) => {
match relay_frame? { match relay_frame? {
GatewayReceiveOutcome::Accepted(relay_frame) => { GatewayReceiveOutcome::Accepted(relay_frame) => {
cam_refresh if let Some(drop_reason) = remote_clients.relay_frame_drop_reason(
.observe_remote_frame(relay_frame.source_peer_id(), relay_frame.payload())?; 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?; write_lan_ethernet(&packet_socket, relay_frame.payload()).await?;
println!( println!(
"{}", "{}",
@@ -406,7 +422,7 @@ impl GatewayConnection {
} }
} }
_ = cam_refresh_tick.tick() => { _ = 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?; write_lan_ethernet(&packet_socket, &frame).await?;
} }
} }
@@ -419,7 +435,7 @@ 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?;
cam_refresh.observe_control_event(&control_event); remote_clients.observe_control_event(&control_event);
println!("{}", format_gateway_control_event(&control_event)); println!("{}", format_gateway_control_event(&control_event));
} }
} }
@@ -782,13 +798,13 @@ async fn write_lan_ethernet(packet_socket: &AsyncFd<PacketSocket>, frame: &[u8])
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct CamRefresh { struct RemoteClientTable {
gateway_mac: MacAddr, gateway_mac: MacAddr,
remote_clients: BTreeMap<u32, MacAddr>, remote_clients: BTreeMap<u32, MacAddr>,
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
impl CamRefresh { impl RemoteClientTable {
fn new(gateway_mac: MacAddr) -> Self { fn new(gateway_mac: MacAddr) -> Self {
Self { Self {
gateway_mac, 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<Option<DropReason>> {
let frame = EthernetFrame::parse(frame).context("relay Ethernet frame is malformed")?; let frame = EthernetFrame::parse(frame).context("relay Ethernet frame is malformed")?;
let source = frame.source(); let Some(expected_source) = self.remote_clients.get(&peer_id) else {
if source.is_valid_client_identity() { return Ok(Some(DropReason::UnauthorizedSourceMac));
self.remote_clients.insert(peer_id, source); };
if frame.source() != *expected_source {
return Ok(Some(DropReason::UnauthorizedSourceMac));
} }
Ok(()) Ok(None)
} }
fn observe_control_event(&mut self, event: &ControlMessage) { fn observe_control_event(&mut self, event: &ControlMessage) {
@@ -817,6 +835,7 @@ impl CamRefresh {
fn observe_peer_joined(&mut self, peer: &PeerInfo) { fn observe_peer_joined(&mut self, peer: &PeerInfo) {
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()
{ {
self.remote_clients.insert(peer.peer_id(), mac); 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 endpoint = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
let server_addr = endpoint.local_addr().unwrap(); let server_addr = endpoint.local_addr().unwrap();
let (stats_received_tx, stats_received_rx) = tokio::sync::oneshot::channel(); 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 server_task = tokio::spawn(async move {
let incoming = endpoint.accept().await.unwrap(); let incoming = endpoint.accept().await.unwrap();
let connection = incoming.await.unwrap(); let connection = incoming.await.unwrap();
@@ -1033,7 +1054,7 @@ mod tests {
send.finish().unwrap(); send.finish().unwrap();
let joined = encode_control_message(&ControlMessage::PeerJoined( 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(); .unwrap();
let mut event_send = connection.open_uni().await.unwrap(); let mut event_send = connection.open_uni().await.unwrap();
@@ -1065,7 +1086,7 @@ mod tests {
7, 7,
99, 99,
0, 0,
&ethernet_frame(b"from relay"), &ethernet_frame_from(server_remote_client_mac, b"from relay"),
) )
.unwrap(); .unwrap();
connection.send_datagram(Bytes::from(response)).unwrap(); connection.send_datagram(Bytes::from(response)).unwrap();
@@ -1121,7 +1142,7 @@ mod tests {
}; };
assert_eq!(peer.peer_id(), 99); assert_eq!(peer.peer_id(), 99);
assert_eq!(peer.role(), Role::Client); 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!( assert!(
gateway gateway
@@ -1139,7 +1160,10 @@ mod tests {
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_eq!(received.source_peer_id(), 99); 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()); assert!(gateway.send_ethernet(&[0; 4]).is_err());
let oversized_payload = vec![0; usize::from(gateway.quic_max_datagram_size())]; let oversized_payload = vec![0; usize::from(gateway.quic_max_datagram_size())];
@@ -1206,26 +1230,41 @@ mod tests {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[test] #[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 gateway_mac = MacAddr::new([0x0a, 0, 0, 0, 0, 1]);
let remote_mac = MacAddr::new([0x02, 0, 0, 0, 0, 2]); let remote_mac = MacAddr::new([0x02, 0, 0, 0, 0, 2]);
let invalid_remote_mac = MacAddr::BROADCAST; let wrong_remote_mac = MacAddr::new([0x02, 0, 0, 0, 0, 3]);
let mut refresh = CamRefresh::new(gateway_mac); let mut remote_clients = RemoteClientTable::new(gateway_mac);
refresh assert_eq!(
.observe_remote_frame(7, &ethernet_frame_from(remote_mac, b"remote")) remote_clients
.unwrap(); .relay_frame_drop_reason(7, &ethernet_frame_from(remote_mac, b"unknown peer"))
refresh .unwrap(),
.observe_remote_frame(8, &ethernet_frame_from(invalid_remote_mac, b"ignored")) Some(DropReason::UnauthorizedSourceMac)
.unwrap(); );
let frames = refresh.refresh_frames(); remote_clients.observe_control_event(&ControlMessage::PeerJoined(
let refresh_frame = EthernetFrame::parse(&frames[0]).unwrap(); PeerInfo::new(7, Role::Client, Some(remote_mac)).unwrap(),
));
assert_eq!(refresh.remote_mac_count(), 1); assert_eq!(
assert_eq!(frames.len(), 1); remote_clients
assert_eq!(refresh_frame.source(), remote_mac); .relay_frame_drop_reason(7, &ethernet_frame_from(remote_mac, b"valid"))
assert_eq!(refresh_frame.destination(), gateway_mac); .unwrap(),
None
);
assert_eq!(
remote_clients
.relay_frame_drop_reason(7, &ethernet_frame_from(wrong_remote_mac, b"forged"))
.unwrap(),
Some(DropReason::UnauthorizedSourceMac)
);
assert_eq!(
remote_clients
.relay_frame_drop_reason(8, &ethernet_frame_from(remote_mac, b"wrong peer"))
.unwrap(),
Some(DropReason::UnauthorizedSourceMac)
);
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -1233,7 +1272,7 @@ mod tests {
fn updates_cam_refresh_from_lifecycle_events() { fn updates_cam_refresh_from_lifecycle_events() {
let gateway_mac = MacAddr::new([0x0a, 0, 0, 0, 0, 1]); let gateway_mac = MacAddr::new([0x0a, 0, 0, 0, 0, 1]);
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 = CamRefresh::new(gateway_mac); let mut refresh = RemoteClientTable::new(gateway_mac);
refresh.observe_control_event(&ControlMessage::PeerJoined( refresh.observe_control_event(&ControlMessage::PeerJoined(
PeerInfo::new(7, Role::Client, Some(remote_mac)).unwrap(), PeerInfo::new(7, Role::Client, Some(remote_mac)).unwrap(),