fix(eBPF): correctly set Ethernet addresses (#8601)

At present, the eBPF code assumes that the incoming packet needs to be
sent back to the same MAC address that it came from. This is only true
if there is at least one IP layer hop in-between the relay and the
Client / Gateway. When setting up Firezone in my local LAN to debug the
eBPF code, all components are within the same subnet and thus can send
packets directly to each other, without having to go through the router.
In such a scenario, simply swapping the Ethernet addresses is not
correct.

As part of witnessing traffic coming in via the network, we can build up
a mapping of IP to MAC address. This mapping can then later be used to
set the correct MAC address for a given destination IP. All of this
functions entirely without interaction from userspace.

Unless you are running in a LAN environment, most if not all IPs will
point to the same MAC address (the one of the next IP layer hop, i.e.
the router). For the very first packet that we want to relay, we will
not have a MAC address for the destination IP. This doesn't matter
though, we simply pass that packet up to userspace and handle it there.
Pretty much all communication on the Internet is bi-directional because
you need some kind of ACK. As soon as we receive the first ACK, e.g. the
response to a binding request, we will learn the MAC address for the
given target IP and the eBPF router can kick in for all packets going
forward.

Related: #7518
This commit is contained in:
Thomas Eizinger
2025-04-02 14:20:37 +11:00
committed by GitHub
parent 42d742e3df
commit fb1311991a
4 changed files with 142 additions and 22 deletions

View File

@@ -6,6 +6,7 @@ pub enum Error {
NotUdp,
NotTurn,
NotIp,
NoMacAddress,
Ipv4PacketWithOptions,
NotAChannelDataMessage,
BadChannelDataLength,
@@ -22,6 +23,7 @@ impl aya_log_ebpf::WriteToBuf for Error {
Error::NotUdp => "Not a UDP packet",
Error::NotTurn => "Not TURN traffic",
Error::NotIp => "Not an IP packet",
Error::NoMacAddress => "No MAC address",
Error::Ipv4PacketWithOptions => "IPv4 packet has options",
Error::NotAChannelDataMessage => "Not a channel data message",
Error::BadChannelDataLength => "Channel data length does not match packet length",

View File

@@ -1,8 +1,13 @@
use aya_ebpf::programs::XdpContext;
use aya_ebpf::{macros::map, maps::HashMap};
use network_types::eth::{EthHdr, EtherType};
use core::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use crate::{error::Error, slice_mut_at::slice_mut_at};
const MAX_ETHERNET_MAPPINGS: u32 = 0x100000;
pub struct Eth<'a> {
inner: &'a mut EthHdr,
}
@@ -19,7 +24,75 @@ impl<'a> Eth<'a> {
self.inner.ether_type
}
pub fn swap_src_and_dst(&mut self) {
core::mem::swap(&mut self.inner.src_addr, &mut self.inner.dst_addr);
pub fn src(&self) -> [u8; 6] {
self.inner.src_addr
}
pub fn dst(&self) -> [u8; 6] {
self.inner.dst_addr
}
/// Update the Ethernet header with the appropriate destination MAC address based on the new destination IP.
pub fn update(self, new_dst_ip: impl Into<IpAddr>) -> Result<(), Error> {
let new_dst_mac = match new_dst_ip.into() {
IpAddr::V4(ip) => get_mac_for_ipv4(ip).ok_or(Error::NoMacAddress)?,
IpAddr::V6(ip) => get_mac_for_ipv6(ip).ok_or(Error::NoMacAddress)?,
};
self.inner.src_addr = self.inner.dst_addr;
self.inner.dst_addr = new_dst_mac;
Ok(())
}
}
#[map]
static IP4_TO_MAC: HashMap<[u8; 4], [u8; 6]> = HashMap::with_max_entries(MAX_ETHERNET_MAPPINGS, 0);
#[map]
static IP6_TO_MAC: HashMap<[u8; 16], [u8; 6]> = HashMap::with_max_entries(MAX_ETHERNET_MAPPINGS, 0);
pub(crate) fn get_mac_for_ipv4(ip: Ipv4Addr) -> Option<[u8; 6]> {
unsafe { IP4_TO_MAC.get(&ip.octets()).copied() }
}
pub(crate) fn get_mac_for_ipv6(ip: Ipv6Addr) -> Option<[u8; 6]> {
unsafe { IP6_TO_MAC.get(&ip.octets()).copied() }
}
pub(crate) fn save_mac_for_ipv4(ip: Ipv4Addr, mac: [u8; 6]) {
let _ = IP4_TO_MAC.insert(&ip.octets(), &mac, 0);
}
pub(crate) fn save_mac_for_ipv6(ip: Ipv6Addr, mac: [u8; 6]) {
let _ = IP6_TO_MAC.insert(&ip.octets(), &mac, 0);
}
#[cfg(test)]
mod tests {
use super::*;
/// Memory overhead of an eBPF map.
///
/// Determined empirically.
const HASH_MAP_OVERHEAD: f32 = 1.5;
#[test]
fn hashmaps_are_less_than_100_mb() {
let ipv4_datatypes = 4 + 6;
let ipv6_datatypes = 16 + 6;
let ipv4_map_size =
ipv4_datatypes as f32 * MAX_ETHERNET_MAPPINGS as f32 * HASH_MAP_OVERHEAD;
let ipv6_map_size =
ipv6_datatypes as f32 * MAX_ETHERNET_MAPPINGS as f32 * HASH_MAP_OVERHEAD;
let total_map_size = (ipv4_map_size + ipv6_map_size) * 2_f32;
let total_map_size_mb = total_map_size / 1024_f32 / 1024_f32;
assert!(
total_map_size_mb < 100_f32,
"Map size is {total_map_size_mb} MB"
);
}
}

View File

@@ -14,14 +14,17 @@ pub struct Ip4<'a> {
impl<'a> Ip4<'a> {
#[inline(always)]
pub fn parse(ctx: &'a XdpContext) -> Result<Self, Error> {
let hdr = slice_mut_at::<Ipv4Hdr>(ctx, EthHdr::LEN)?;
let ip4_hdr = slice_mut_at::<Ipv4Hdr>(ctx, EthHdr::LEN)?;
// IPv4 packets with options are handled in user-space.
if usize::from(hdr.ihl()) * 4 != Ipv4Hdr::LEN {
if usize::from(ip4_hdr.ihl()) * 4 != Ipv4Hdr::LEN {
return Err(Error::Ipv4PacketWithOptions);
}
Ok(Self { ctx, inner: hdr })
Ok(Self {
ctx,
inner: ip4_hdr,
})
}
pub fn src(&self) -> Ipv4Addr {

View File

@@ -67,6 +67,7 @@ pub fn handle_turn(ctx: XdpContext) -> u32 {
| Error::XdpAdjustHeadFailed
| Error::XdpLoadBytesFailed
| Error::PacketTooShort
| Error::NoMacAddress
| Error::NoChannelBinding => {
debug!(&ctx, "Failed to handle packet: {}", e);
@@ -86,21 +87,23 @@ fn try_handle_turn(ctx: &XdpContext) -> Result<u32, Error> {
let eth = Eth::parse(ctx)?;
match eth.ether_type() {
EtherType::Ipv4 => try_handle_turn_ipv4(ctx)?,
EtherType::Ipv6 => try_handle_turn_ipv6(ctx)?,
EtherType::Ipv4 => try_handle_turn_ipv4(ctx, eth)?,
EtherType::Ipv6 => try_handle_turn_ipv6(ctx, eth)?,
_ => return Err(Error::NotIp),
};
// If we get to here, we modified the packet and need to send it back out again.
Eth::parse(ctx)?.swap_src_and_dst();
Ok(xdp_action::XDP_TX)
}
#[inline(always)]
fn try_handle_turn_ipv4(ctx: &XdpContext) -> Result<(), Error> {
fn try_handle_turn_ipv4(ctx: &XdpContext, eth: Eth) -> Result<(), Error> {
let ipv4 = Ip4::parse(ctx)?;
eth::save_mac_for_ipv4(ipv4.src(), eth.src());
eth::save_mac_for_ipv4(ipv4.dst(), eth.dst());
if ipv4.protocol() != IpProto::Udp {
return Err(Error::NotUdp);
}
@@ -119,14 +122,14 @@ fn try_handle_turn_ipv4(ctx: &XdpContext) -> Result<(), Error> {
);
if config::allocation_range().contains(&udp.dst()) {
try_handle_ipv4_udp_to_channel_data(ctx, ipv4, udp)?;
try_handle_ipv4_udp_to_channel_data(ctx, eth, ipv4, udp)?;
stats::emit_data_relayed(ctx, udp_payload_len);
return Ok(());
}
if udp.dst() == 3478 {
try_handle_ipv4_channel_data_to_udp(ctx, ipv4, udp)?;
try_handle_ipv4_channel_data_to_udp(ctx, eth, ipv4, udp)?;
stats::emit_data_relayed(ctx, udp_payload_len - CdHdr::LEN as u16);
return Ok(());
@@ -136,7 +139,12 @@ fn try_handle_turn_ipv4(ctx: &XdpContext) -> Result<(), Error> {
}
#[inline(always)]
fn try_handle_ipv4_channel_data_to_udp(ctx: &XdpContext, ipv4: Ip4, udp: Udp) -> Result<(), Error> {
fn try_handle_ipv4_channel_data_to_udp(
ctx: &XdpContext,
eth: Eth,
ipv4: Ip4,
udp: Udp,
) -> Result<(), Error> {
let cd = ChannelData::parse(ctx, Ipv4Hdr::LEN)?;
// SAFETY: ???
@@ -145,8 +153,12 @@ fn try_handle_ipv4_channel_data_to_udp(ctx: &XdpContext, ipv4: Ip4, udp: Udp) ->
.ok_or(Error::NoChannelBinding)?;
let new_src = ipv4.dst(); // The IP we received the packet on will be the new source IP.
let new_dst = port_and_peer.peer_ip();
let new_ipv4_total_len = ipv4.total_len() - CdHdr::LEN as u16;
let pseudo_header = ipv4.update(new_src, port_and_peer.peer_ip(), new_ipv4_total_len);
eth.update(new_dst)?;
let pseudo_header = ipv4.update(new_src, new_dst, new_ipv4_total_len);
let new_udp_len = udp.len() - CdHdr::LEN as u16;
udp.update(
@@ -164,14 +176,23 @@ fn try_handle_ipv4_channel_data_to_udp(ctx: &XdpContext, ipv4: Ip4, udp: Udp) ->
}
#[inline(always)]
fn try_handle_ipv4_udp_to_channel_data(ctx: &XdpContext, ipv4: Ip4, udp: Udp) -> Result<(), Error> {
fn try_handle_ipv4_udp_to_channel_data(
ctx: &XdpContext,
eth: Eth,
ipv4: Ip4,
udp: Udp,
) -> Result<(), Error> {
let client_and_channel =
unsafe { UDP_TO_CHAN_44.get(&PortAndPeerV4::new(ipv4.src(), udp.dst(), udp.src())) }
.ok_or(Error::NoChannelBinding)?;
let new_src = ipv4.dst(); // The IP we received the packet on will be the new source IP.
let new_dst = client_and_channel.client_ip();
let new_ipv4_total_len = ipv4.total_len() + CdHdr::LEN as u16;
let pseudo_header = ipv4.update(new_src, client_and_channel.client_ip(), new_ipv4_total_len);
eth.update(new_dst)?;
let pseudo_header = ipv4.update(new_src, new_dst, new_ipv4_total_len);
let udp_len = udp.len();
let new_udp_len = udp_len + CdHdr::LEN as u16;
@@ -199,9 +220,12 @@ fn try_handle_ipv4_udp_to_channel_data(ctx: &XdpContext, ipv4: Ip4, udp: Udp) ->
}
#[inline(always)]
fn try_handle_turn_ipv6(ctx: &XdpContext) -> Result<(), Error> {
fn try_handle_turn_ipv6(ctx: &XdpContext, eth: Eth) -> Result<(), Error> {
let ipv6 = Ip6::parse(ctx)?;
eth::save_mac_for_ipv6(ipv6.src(), eth.src());
eth::save_mac_for_ipv6(ipv6.dst(), eth.dst());
if ipv6.protocol() != IpProto::Udp {
return Err(Error::NotUdp);
}
@@ -220,14 +244,14 @@ fn try_handle_turn_ipv6(ctx: &XdpContext) -> Result<(), Error> {
);
if config::allocation_range().contains(&udp.dst()) {
try_handle_ipv6_udp_to_channel_data(ctx, ipv6, udp)?;
try_handle_ipv6_udp_to_channel_data(ctx, eth, ipv6, udp)?;
stats::emit_data_relayed(ctx, udp_payload_len);
return Ok(());
}
if udp.dst() == 3478 {
try_handle_ipv6_channel_data_to_udp(ctx, ipv6, udp)?;
try_handle_ipv6_channel_data_to_udp(ctx, eth, ipv6, udp)?;
stats::emit_data_relayed(ctx, udp_payload_len - CdHdr::LEN as u16);
return Ok(());
@@ -236,14 +260,23 @@ fn try_handle_turn_ipv6(ctx: &XdpContext) -> Result<(), Error> {
Err(Error::NotTurn)
}
fn try_handle_ipv6_udp_to_channel_data(ctx: &XdpContext, ipv6: Ip6, udp: Udp) -> Result<(), Error> {
fn try_handle_ipv6_udp_to_channel_data(
ctx: &XdpContext,
eth: Eth,
ipv6: Ip6,
udp: Udp,
) -> Result<(), Error> {
let client_and_channel =
unsafe { UDP_TO_CHAN_66.get(&PortAndPeerV6::new(ipv6.src(), udp.dst(), udp.src())) }
.ok_or(Error::NoChannelBinding)?;
let new_src = ipv6.dst(); // The IP we received the packet on will be the new source IP.
let new_dst = client_and_channel.client_ip();
let new_ipv6_total_len = ipv6.payload_len() + CdHdr::LEN as u16;
let pseudo_header = ipv6.update(new_src, client_and_channel.client_ip(), new_ipv6_total_len);
eth.update(new_dst)?;
let pseudo_header = ipv6.update(new_src, new_dst, new_ipv6_total_len);
let udp_len = udp.len();
let new_udp_len = udp_len + CdHdr::LEN as u16;
@@ -270,7 +303,12 @@ fn try_handle_ipv6_udp_to_channel_data(ctx: &XdpContext, ipv6: Ip6, udp: Udp) ->
Ok(())
}
fn try_handle_ipv6_channel_data_to_udp(ctx: &XdpContext, ipv6: Ip6, udp: Udp) -> Result<(), Error> {
fn try_handle_ipv6_channel_data_to_udp(
ctx: &XdpContext,
eth: Eth,
ipv6: Ip6,
udp: Udp,
) -> Result<(), Error> {
let cd = ChannelData::parse(ctx, Ipv6Hdr::LEN)?;
// SAFETY: ???
@@ -279,8 +317,12 @@ fn try_handle_ipv6_channel_data_to_udp(ctx: &XdpContext, ipv6: Ip6, udp: Udp) ->
.ok_or(Error::NoChannelBinding)?;
let new_src = ipv6.dst(); // The IP we received the packet on will be the new source IP.
let new_dst = port_and_peer.peer_ip();
let new_ipv6_payload_len = ipv6.payload_len() - CdHdr::LEN as u16;
let pseudo_header = ipv6.update(new_src, port_and_peer.peer_ip(), new_ipv6_payload_len);
eth.update(new_dst)?;
let pseudo_header = ipv6.update(new_src, new_dst, new_ipv6_payload_len);
let new_udp_len = udp.len() - CdHdr::LEN as u16;
udp.update(