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:
@@ -291,7 +291,7 @@ 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 remote_clients = RemoteClientTable::new(packet_socket.interface_mac());
|
||||
let mut cam_refresh_tick = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + CAM_REFRESH_INTERVAL,
|
||||
CAM_REFRESH_INTERVAL,
|
||||
@@ -375,8 +375,24 @@ impl GatewayConnection {
|
||||
relay_frame = recv_gateway_ethernet_outcome(&connection, &welcome, &stats) => {
|
||||
match relay_frame? {
|
||||
GatewayReceiveOutcome::Accepted(relay_frame) => {
|
||||
cam_refresh
|
||||
.observe_remote_frame(relay_frame.source_peer_id(), relay_frame.payload())?;
|
||||
if let Some(drop_reason) = remote_clients.relay_frame_drop_reason(
|
||||
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?;
|
||||
println!(
|
||||
"{}",
|
||||
@@ -406,7 +422,7 @@ impl GatewayConnection {
|
||||
}
|
||||
}
|
||||
_ = 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?;
|
||||
}
|
||||
}
|
||||
@@ -419,7 +435,7 @@ impl GatewayConnection {
|
||||
let control_stream = control_stream
|
||||
.context("failed to accept gateway control event stream")?;
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -782,13 +798,13 @@ async fn write_lan_ethernet(packet_socket: &AsyncFd<PacketSocket>, frame: &[u8])
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Debug, Clone)]
|
||||
struct CamRefresh {
|
||||
struct RemoteClientTable {
|
||||
gateway_mac: MacAddr,
|
||||
remote_clients: BTreeMap<u32, MacAddr>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl CamRefresh {
|
||||
impl RemoteClientTable {
|
||||
fn new(gateway_mac: MacAddr) -> Self {
|
||||
Self {
|
||||
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 source = frame.source();
|
||||
if source.is_valid_client_identity() {
|
||||
self.remote_clients.insert(peer_id, source);
|
||||
let Some(expected_source) = self.remote_clients.get(&peer_id) else {
|
||||
return Ok(Some(DropReason::UnauthorizedSourceMac));
|
||||
};
|
||||
if frame.source() != *expected_source {
|
||||
return Ok(Some(DropReason::UnauthorizedSourceMac));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn observe_control_event(&mut self, event: &ControlMessage) {
|
||||
@@ -817,6 +835,7 @@ impl CamRefresh {
|
||||
fn observe_peer_joined(&mut self, peer: &PeerInfo) {
|
||||
if peer.role() == Role::Client
|
||||
&& let Some(mac) = peer.mac()
|
||||
&& mac.is_valid_client_identity()
|
||||
{
|
||||
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 server_addr = endpoint.local_addr().unwrap();
|
||||
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 incoming = endpoint.accept().await.unwrap();
|
||||
let connection = incoming.await.unwrap();
|
||||
@@ -1033,7 +1054,7 @@ mod tests {
|
||||
send.finish().unwrap();
|
||||
|
||||
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();
|
||||
let mut event_send = connection.open_uni().await.unwrap();
|
||||
@@ -1065,7 +1086,7 @@ mod tests {
|
||||
7,
|
||||
99,
|
||||
0,
|
||||
ðernet_frame(b"from relay"),
|
||||
ðernet_frame_from(server_remote_client_mac, b"from relay"),
|
||||
)
|
||||
.unwrap();
|
||||
connection.send_datagram(Bytes::from(response)).unwrap();
|
||||
@@ -1121,7 +1142,7 @@ mod tests {
|
||||
};
|
||||
assert_eq!(peer.peer_id(), 99);
|
||||
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!(
|
||||
gateway
|
||||
@@ -1139,7 +1160,10 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
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());
|
||||
let oversized_payload = vec![0; usize::from(gateway.quic_max_datagram_size())];
|
||||
@@ -1206,26 +1230,41 @@ mod tests {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[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 remote_mac = MacAddr::new([0x02, 0, 0, 0, 0, 2]);
|
||||
let invalid_remote_mac = MacAddr::BROADCAST;
|
||||
let mut refresh = CamRefresh::new(gateway_mac);
|
||||
let wrong_remote_mac = MacAddr::new([0x02, 0, 0, 0, 0, 3]);
|
||||
let mut remote_clients = RemoteClientTable::new(gateway_mac);
|
||||
|
||||
refresh
|
||||
.observe_remote_frame(7, ðernet_frame_from(remote_mac, b"remote"))
|
||||
.unwrap();
|
||||
refresh
|
||||
.observe_remote_frame(8, ðernet_frame_from(invalid_remote_mac, b"ignored"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
remote_clients
|
||||
.relay_frame_drop_reason(7, ðernet_frame_from(remote_mac, b"unknown peer"))
|
||||
.unwrap(),
|
||||
Some(DropReason::UnauthorizedSourceMac)
|
||||
);
|
||||
|
||||
let frames = refresh.refresh_frames();
|
||||
let refresh_frame = EthernetFrame::parse(&frames[0]).unwrap();
|
||||
remote_clients.observe_control_event(&ControlMessage::PeerJoined(
|
||||
PeerInfo::new(7, Role::Client, Some(remote_mac)).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);
|
||||
assert_eq!(
|
||||
remote_clients
|
||||
.relay_frame_drop_reason(7, ðernet_frame_from(remote_mac, b"valid"))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
remote_clients
|
||||
.relay_frame_drop_reason(7, ðernet_frame_from(wrong_remote_mac, b"forged"))
|
||||
.unwrap(),
|
||||
Some(DropReason::UnauthorizedSourceMac)
|
||||
);
|
||||
assert_eq!(
|
||||
remote_clients
|
||||
.relay_frame_drop_reason(8, ðernet_frame_from(remote_mac, b"wrong peer"))
|
||||
.unwrap(),
|
||||
Some(DropReason::UnauthorizedSourceMac)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -1233,7 +1272,7 @@ mod tests {
|
||||
fn updates_cam_refresh_from_lifecycle_events() {
|
||||
let gateway_mac = MacAddr::new([0x0a, 0, 0, 0, 0, 1]);
|
||||
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(
|
||||
PeerInfo::new(7, Role::Client, Some(remote_mac)).unwrap(),
|
||||
|
||||
Reference in New Issue
Block a user