use std::net::IpAddr;
use crate::error::OverlayError;
const VIRTUAL_IFACE_PREFIXES: &[&str] = &[
"lo", "wt", "wg", "zl-", "utun", "tun", "tap", "docker", "br-", "veth", "nb",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PhysicalEgress {
pub interface: String,
pub ip: IpAddr,
}
#[must_use]
pub fn is_virtual_interface(name: &str) -> bool {
VIRTUAL_IFACE_PREFIXES
.iter()
.any(|prefix| name.starts_with(prefix))
}
fn udp_connect_local_ipv4() -> std::result::Result<IpAddr, std::io::Error> {
let socket = std::net::UdpSocket::bind("0.0.0.0:0")?;
socket.connect("8.8.8.8:80")?;
Ok(socket.local_addr()?.ip())
}
#[cfg(target_os = "linux")]
fn udp_connect_local_ipv6() -> std::result::Result<IpAddr, std::io::Error> {
let socket = std::net::UdpSocket::bind("[::]:0")?;
socket.connect("[2001:4860:4860::8888]:80")?;
Ok(socket.local_addr()?.ip())
}
pub async fn detect_physical_egress() -> Result<PhysicalEgress, OverlayError> {
#[cfg(target_os = "linux")]
{
linux::detect().await
}
#[cfg(target_os = "macos")]
{
macos::detect()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let ip = udp_connect_local_ipv4().map_err(|e| {
OverlayError::NetworkConfig(format!(
"failed to determine local egress IP via UDP-connect: {e}"
))
})?;
Ok(PhysicalEgress {
interface: String::new(),
ip,
})
}
}
#[cfg(unix)]
#[cfg_attr(
not(any(target_os = "linux", target_os = "macos")),
allow(unused_variables)
)]
pub fn bind_to_device<S>(socket: &S, interface: &str) -> Result<(), OverlayError>
where
S: std::os::fd::AsRawFd,
{
#[cfg(target_os = "linux")]
{
linux::bind_to_device(socket.as_raw_fd(), interface)
}
#[cfg(target_os = "macos")]
{
macos::bind_to_device(socket.as_raw_fd(), interface)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
Ok(())
}
}
#[cfg(not(unix))]
#[allow(clippy::missing_errors_doc)]
pub fn bind_to_device<S>(_socket: &S, _interface: &str) -> Result<(), OverlayError> {
Ok(())
}
#[cfg(target_os = "linux")]
mod linux {
#![allow(unsafe_code)]
use std::net::IpAddr;
use futures_util::stream::TryStreamExt;
use netlink_packet_route::{
address::{AddressAttribute, AddressScope},
link::{LinkAttribute, LinkFlag},
route::{RouteAttribute, RouteHeader},
};
use rtnetlink::{Handle, IpVersion};
use super::{
is_virtual_interface, udp_connect_local_ipv4, udp_connect_local_ipv6, PhysicalEgress,
};
use crate::error::OverlayError;
struct DefaultRoute {
oif: u32,
metric: u32,
}
pub(super) async fn detect() -> Result<PhysicalEgress, OverlayError> {
let (connection, handle, _) = rtnetlink::new_connection().map_err(|e| {
OverlayError::NetworkConfig(format!("rtnetlink new_connection failed: {e}"))
})?;
tokio::spawn(connection);
let mut defaults = collect_default_routes(&handle, IpVersion::V4).await?;
let v4_only_defaults = defaults.len();
defaults.extend(collect_default_routes(&handle, IpVersion::V6).await?);
defaults.sort_by_key(|r| r.metric);
for route in &defaults {
let Some(name) = link_name_for_index(&handle, route.oif).await? else {
continue;
};
if is_virtual_interface(&name) {
continue;
}
if let Some(ip) = primary_global_addr(&handle, route.oif).await? {
return Ok(PhysicalEgress {
interface: name,
ip,
});
}
}
if let Some(egress) = first_non_virtual_up_with_addr(&handle).await? {
tracing::warn!(
v4_default_routes = v4_only_defaults,
total_default_routes = defaults.len(),
"all default routes egress through virtual/mesh interfaces; \
selected first non-virtual UP interface '{}' as physical egress",
egress.interface,
);
return Ok(egress);
}
let ip = udp_connect_local_ipv4()
.or_else(|_| udp_connect_local_ipv6())
.map_err(|e| {
OverlayError::NetworkConfig(format!(
"no physical egress interface found and UDP-connect fallback failed: {e}"
))
})?;
tracing::warn!(
resolved_ip = %ip,
"could not distinguish a physical NIC from virtual/mesh interfaces; \
falling back to UDP-connect source IP (may be a VPN-mesh address)"
);
Ok(PhysicalEgress {
interface: String::new(),
ip,
})
}
async fn collect_default_routes(
handle: &Handle,
version: IpVersion,
) -> Result<Vec<DefaultRoute>, OverlayError> {
let mut routes = handle.route().get(version).execute();
let mut out = Vec::new();
while let Some(route) = routes
.try_next()
.await
.map_err(|e| OverlayError::NetworkConfig(format!("route dump failed: {e}")))?
{
let mut table = u32::from(route.header.table);
let mut oif: Option<u32> = None;
let mut metric: u32 = 0;
if route.header.destination_prefix_length != 0 {
continue;
}
for attr in &route.attributes {
match attr {
RouteAttribute::Oif(idx) => oif = Some(*idx),
RouteAttribute::Priority(m) => metric = *m,
RouteAttribute::Table(t) => table = *t,
_ => {}
}
}
if table != u32::from(RouteHeader::RT_TABLE_MAIN) {
continue;
}
if let Some(oif) = oif {
out.push(DefaultRoute { oif, metric });
}
}
Ok(out)
}
async fn link_name_for_index(
handle: &Handle,
index: u32,
) -> Result<Option<String>, OverlayError> {
let mut links = handle.link().get().match_index(index).execute();
let Some(link) = links.try_next().await.map_err(|e| {
OverlayError::NetworkConfig(format!("link lookup for index {index} failed: {e}"))
})?
else {
return Ok(None);
};
Ok(link.attributes.iter().find_map(|attr| match attr {
LinkAttribute::IfName(name) => Some(name.clone()),
_ => None,
}))
}
async fn primary_global_addr(
handle: &Handle,
index: u32,
) -> Result<Option<IpAddr>, OverlayError> {
let mut addrs = handle
.address()
.get()
.set_link_index_filter(index)
.execute();
let mut v6: Option<IpAddr> = None;
while let Some(msg) = addrs.try_next().await.map_err(|e| {
OverlayError::NetworkConfig(format!("address dump for index {index} failed: {e}"))
})? {
if msg.header.scope != AddressScope::Universe {
continue;
}
for attr in &msg.attributes {
if let AddressAttribute::Address(ip) = attr {
match ip {
IpAddr::V4(_) => return Ok(Some(*ip)), IpAddr::V6(_) => {
if v6.is_none() {
v6 = Some(*ip);
}
}
}
}
}
}
Ok(v6)
}
async fn first_non_virtual_up_with_addr(
handle: &Handle,
) -> Result<Option<PhysicalEgress>, OverlayError> {
let mut links = handle.link().get().execute();
let mut candidates: Vec<(u32, String)> = Vec::new();
while let Some(link) = links
.try_next()
.await
.map_err(|e| OverlayError::NetworkConfig(format!("link dump failed: {e}")))?
{
let is_up = link.header.flags.contains(&LinkFlag::Up);
if !is_up {
continue;
}
let name = link.attributes.iter().find_map(|attr| match attr {
LinkAttribute::IfName(n) => Some(n.clone()),
_ => None,
});
let Some(name) = name else { continue };
if is_virtual_interface(&name) {
continue;
}
candidates.push((link.header.index, name));
}
for (index, name) in candidates {
if let Some(ip) = primary_global_addr(handle, index).await? {
return Ok(Some(PhysicalEgress {
interface: name,
ip,
}));
}
}
Ok(None)
}
pub(super) fn bind_to_device(fd: i32, interface: &str) -> Result<(), OverlayError> {
let name_bytes = interface.as_bytes();
if name_bytes.len() >= libc::IFNAMSIZ {
return Err(OverlayError::NetworkConfig(format!(
"interface name '{interface}' too long for SO_BINDTODEVICE (max {})",
libc::IFNAMSIZ - 1
)));
}
let rc = unsafe {
libc::setsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_BINDTODEVICE,
name_bytes.as_ptr().cast::<libc::c_void>(),
libc::socklen_t::try_from(name_bytes.len()).unwrap_or(0),
)
};
if rc == 0 {
return Ok(());
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EPERM | libc::EACCES) => Err(OverlayError::PermissionDenied(format!(
"SO_BINDTODEVICE({interface}) requires CAP_NET_RAW or root: {err}"
))),
Some(libc::ENODEV) => Err(OverlayError::InterfaceNotFound(interface.to_string())),
_ => Err(OverlayError::NetworkConfig(format!(
"SO_BINDTODEVICE({interface}) failed: {err}"
))),
}
}
}
#[cfg(target_os = "macos")]
mod macos {
#![allow(unsafe_code)]
use std::ffi::{CStr, CString};
use std::net::{IpAddr, Ipv4Addr};
use super::{is_virtual_interface, udp_connect_local_ipv4, PhysicalEgress};
use crate::error::OverlayError;
struct IfEntry {
name: String,
ip: IpAddr,
is_up: bool,
is_loopback: bool,
}
pub(super) fn detect() -> Result<PhysicalEgress, OverlayError> {
let candidate = udp_connect_local_ipv4().map_err(|e| {
OverlayError::NetworkConfig(format!(
"failed to determine local egress IP via UDP-connect: {e}"
))
})?;
let entries = getifaddrs_entries()?;
let candidate_iface = entries
.iter()
.find(|e| e.ip == candidate)
.map(|e| e.name.clone());
if let Some(name) = candidate_iface {
if !is_virtual_interface(&name) {
return Ok(PhysicalEgress {
interface: name,
ip: candidate,
});
}
}
for entry in &entries {
if !entry.is_up || entry.is_loopback {
continue;
}
if is_virtual_interface(&entry.name) {
continue;
}
if let IpAddr::V4(v4) = entry.ip {
if is_global_ipv4(v4) {
return Ok(PhysicalEgress {
interface: entry.name.clone(),
ip: entry.ip,
});
}
}
}
tracing::warn!(
resolved_ip = %candidate,
"macOS: could not distinguish a physical NIC; returning UDP-connect source IP"
);
Ok(PhysicalEgress {
interface: String::new(),
ip: candidate,
})
}
fn is_global_ipv4(v4: Ipv4Addr) -> bool {
!v4.is_loopback()
&& !v4.is_link_local()
&& !v4.is_unspecified()
&& !v4.is_broadcast()
&& !v4.is_multicast()
}
fn getifaddrs_entries() -> Result<Vec<IfEntry>, OverlayError> {
let mut head: *mut libc::ifaddrs = std::ptr::null_mut();
let rc = unsafe { libc::getifaddrs(&raw mut head) };
if rc != 0 {
return Err(OverlayError::NetworkConfig(format!(
"getifaddrs failed: {}",
std::io::Error::last_os_error()
)));
}
let mut entries = Vec::new();
let mut cur = head;
while !cur.is_null() {
let ifa = unsafe { &*cur };
cur = ifa.ifa_next;
if ifa.ifa_name.is_null() || ifa.ifa_addr.is_null() {
continue;
}
let name = unsafe { CStr::from_ptr(ifa.ifa_name) }
.to_string_lossy()
.into_owned();
let is_up = (ifa.ifa_flags & (libc::IFF_UP as u32)) != 0;
let is_loopback = (ifa.ifa_flags & (libc::IFF_LOOPBACK as u32)) != 0;
let family = i32::from(unsafe { (*ifa.ifa_addr).sa_family });
let ip = match family {
libc::AF_INET => {
let sin = unsafe {
std::ptr::read_unaligned(ifa.ifa_addr.cast::<libc::sockaddr_in>())
};
let raw = u32::from_be(sin.sin_addr.s_addr);
Some(IpAddr::V4(Ipv4Addr::from(raw)))
}
libc::AF_INET6 => {
let sin6 = unsafe {
std::ptr::read_unaligned(ifa.ifa_addr.cast::<libc::sockaddr_in6>())
};
Some(IpAddr::V6(std::net::Ipv6Addr::from(sin6.sin6_addr.s6_addr)))
}
_ => None,
};
if let Some(ip) = ip {
entries.push(IfEntry {
name,
ip,
is_up,
is_loopback,
});
}
}
unsafe { libc::freeifaddrs(head) };
Ok(entries)
}
pub(super) fn bind_to_device(fd: i32, interface: &str) -> Result<(), OverlayError> {
let cname = CString::new(interface).map_err(|_| {
OverlayError::NetworkConfig(format!(
"interface name '{interface}' contains an interior NUL byte"
))
})?;
let idx = unsafe { libc::if_nametoindex(cname.as_ptr()) };
if idx == 0 {
return Err(OverlayError::InterfaceNotFound(interface.to_string()));
}
let idx = libc::c_int::try_from(idx)
.map_err(|_| OverlayError::InterfaceNotFound(interface.to_string()))?;
let rc = unsafe {
libc::setsockopt(
fd,
libc::IPPROTO_IP,
libc::IP_BOUND_IF,
std::ptr::addr_of!(idx).cast::<libc::c_void>(),
#[allow(clippy::cast_possible_truncation)]
{
std::mem::size_of::<libc::c_int>() as libc::socklen_t
},
)
};
if rc == 0 {
return Ok(());
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EPERM | libc::EACCES) => Err(OverlayError::PermissionDenied(format!(
"IP_BOUND_IF({interface}) denied: {err}"
))),
_ => Err(OverlayError::NetworkConfig(format!(
"IP_BOUND_IF({interface}) failed: {err}"
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn virtual_interfaces_are_detected() {
for name in [
"lo",
"lo0",
"wt0",
"wg0",
"zl-abc",
"zl-overlay0",
"utun3",
"tun0",
"tap0",
"docker0",
"br-1",
"veth9a2b",
"nb-wt",
] {
assert!(
is_virtual_interface(name),
"expected '{name}' to be virtual"
);
}
}
#[test]
fn physical_interfaces_are_not_virtual() {
for name in ["eth0", "en0", "enp3s0", "wlan0", "wlp2s0", "bond0"] {
assert!(
!is_virtual_interface(name),
"expected '{name}' to be physical (non-virtual)"
);
}
}
#[test]
fn empty_name_is_not_virtual() {
assert!(!is_virtual_interface(""));
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn detect_physical_egress_on_linux() {
match detect_physical_egress().await {
Ok(egress) => {
assert!(
!egress.ip.is_loopback(),
"resolved egress IP should not be loopback: {egress:?}"
);
if !egress.interface.is_empty() {
assert!(
!is_virtual_interface(&egress.interface),
"resolved egress interface should be physical: {egress:?}"
);
}
}
Err(e) => {
eprintln!("skipping: no physical egress detectable in this environment: {e}");
}
}
}
}