From e3107ae7e9f372501384f330768532dec75608d2 Mon Sep 17 00:00:00 2001 From: shellrow Date: Mon, 3 Aug 2026 23:28:22 +0900 Subject: [PATCH 1/3] fix: resolve IPv6 gateway MAC addresses --- src/os/android/netlink.rs | 25 ++- src/os/bsd/route/mod.rs | 420 +++++++++++++++++++++++++++++++------- src/os/linux/gateway.rs | 70 +++++++ src/os/linux/mod.rs | 2 + src/os/linux/netlink.rs | 25 ++- 5 files changed, 447 insertions(+), 95 deletions(-) create mode 100644 src/os/linux/gateway.rs diff --git a/src/os/android/netlink.rs b/src/os/android/netlink.rs index 670f4ad..b19bddc 100644 --- a/src/os/android/netlink.rs +++ b/src/os/android/netlink.rs @@ -376,24 +376,31 @@ pub fn collect_routes() -> io::Result> { } } + let mut mac_candidates = crate::os::linux::gateway::GatewayMacCandidates::default(); for n in neighs { let (ip, mac, ifi) = neigh_extract(&n); let ifi = match ifi { Some(i) => i, None => continue, }; - if let Some(row) = m.get_mut(&ifi) { - if let (Some(m6), Some(ip)) = (mac, ip) { - let hit = match ip { - IpAddr::V4(v4) => row.gw_v4.contains(&v4), - IpAddr::V6(v6) => row.gw_v6.contains(&v6), - }; - if hit { - row.mac = Some(m6); - } + let (Some(row), Some(m6), Some(ip)) = (m.get_mut(&ifi), mac, ip) else { + continue; + }; + match ip { + IpAddr::V4(v4) if row.gw_v4.contains(&v4) => { + mac_candidates.record_ipv4(ifi, m6); + } + IpAddr::V6(v6) if row.gw_v6.contains(&v6) => { + mac_candidates.record_ipv6(ifi, m6); } + _ => {} } } + for (ifindex, row) in &mut m { + // Preserve the established IPv4 result when both families use different routers. + row.mac = mac_candidates.get(*ifindex); + } + Ok(m) } diff --git a/src/os/bsd/route/mod.rs b/src/os/bsd/route/mod.rs index f8eae4a..2949855 100644 --- a/src/os/bsd/route/mod.rs +++ b/src/os/bsd/route/mod.rs @@ -29,7 +29,10 @@ const CTL_NET: c_int = libc::CTL_NET; const NET_RT_DUMP: c_int = 1; const NET_RT_FLAGS: c_int = 2; +#[cfg(any(target_os = "freebsd", target_os = "openbsd"))] const RTF_LLINFO: u32 = 1024; +#[cfg(target_os = "netbsd")] +const RTF_LLDATA: u32 = 1024; #[cfg(any(target_os = "freebsd", target_os = "openbsd"))] const RTM_VERSION: u8 = 5; #[cfg(target_os = "netbsd")] @@ -135,32 +138,48 @@ fn roundup(len: usize) -> usize { } } +#[cfg(any(target_os = "netbsd", target_os = "openbsd"))] #[inline] -fn normalize_scoped_v6(gw: Ipv6Addr) -> Ipv6Addr { - // Unicast link-local: fe80::/10 (in practice often fe80::/64) - let is_unicast_ll = gw.segments()[0] == 0xfe80; +fn has_embedded_scope(addr: Ipv6Addr) -> bool { + let oct = addr.octets(); + // Unicast link-local: fe80::/10. + let is_unicast_ll = oct[0] == 0xfe && (oct[1] & 0xc0) == 0x80; - // Multicast check (ff00::/8) and local scopes: 0x1 (node-local) or 0x2 (link-local). - let oct = gw.octets(); + // Multicast local scopes: 0x1 (interface-local) or 0x2 (link-local). let is_multicast = oct[0] == 0xff; let mscope = oct[1] & 0x0f; let is_local_scope_mc = is_multicast && (mscope == 0x1 || mscope == 0x2); - if is_unicast_ll || is_local_scope_mc { - let segs = gw.segments(); - Ipv6Addr::new( - segs[0], 0, segs[2], segs[3], segs[4], segs[5], segs[6], segs[7], - ) - } else { - gw + is_unicast_ll || is_local_scope_mc +} + +#[cfg(any(target_os = "netbsd", target_os = "openbsd"))] +#[inline] +fn normalize_scoped_v6(addr: Ipv6Addr, scope_id: u32) -> (Ipv6Addr, u32) { + if scope_id != 0 || !has_embedded_scope(addr) { + return (addr, scope_id); } + + let mut octets = addr.octets(); + let embedded_scope = u16::from_be_bytes([octets[2], octets[3]]) as u32; + octets[2] = 0; + octets[3] = 0; + (Ipv6Addr::from(octets), embedded_scope) +} + +#[cfg(target_os = "freebsd")] +#[inline] +fn normalize_scoped_v6(addr: Ipv6Addr, scope_id: u32) -> (Ipv6Addr, u32) { + // FreeBSD exposes the scope separately (or leaves it for sockaddr_dl), unlike the + // KAME representation still used by NetBSD and OpenBSD routing messages. + (addr, scope_id) } #[inline] fn normalize_gateway(ip: IpAddr) -> IpAddr { match ip { IpAddr::V4(v4) => IpAddr::V4(v4), - IpAddr::V6(v6) => IpAddr::V6(normalize_scoped_v6(v6)), + IpAddr::V6(v6) => IpAddr::V6(normalize_scoped_v6(v6, 0).0), } } @@ -237,12 +256,38 @@ fn code_to_error(err: i32) -> io::Error { io::Error::new(kind, format!("rtm_errno {}", err)) } -/// Extract `(IP, MAC)` pair from a routing message's address block. -fn message_to_arppair(msg: &[u8]) -> Option<(IpAddr, MacAddr)> { +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +struct NeighborEntry { + ip: IpAddr, + ifindex: u32, + mac: MacAddr, +} + +const SOCKADDR_DL_HEADER_LEN: usize = 8; + +/// Extract a neighbor entry from a routing message's address block. +fn message_to_neighbor(msg: &[u8], header_ifindex: u32) -> Option { let mut off = 0usize; - let mut ip: Option = None; + let mut ip: Option = None; + let mut scope_id = 0u32; + let mut link_ifindex = 0u32; let mut mac: Option = None; + let make_entry = + |ip: Option, mac: Option, link_ifindex: u32, scope_id: u32| { + Some(NeighborEntry { + ip: ip?, + ifindex: if link_ifindex != 0 { + link_ifindex + } else if scope_id != 0 { + scope_id + } else { + header_ifindex + }, + mac: mac?, + }) + }; + while off + core::mem::size_of::() <= msg.len() { let sa = unsafe { &*(msg[off..].as_ptr() as *const libc::sockaddr) }; let sa_len = sa.sa_len as usize; @@ -258,43 +303,47 @@ fn message_to_arppair(msg: &[u8]) -> Option<(IpAddr, MacAddr)> { match sa.sa_family as c_int { x if x == libc::AF_INET => { - if let Some(IpAddr::V4(v4)) = ip_from_sockaddr(sa) { - ip = Some(v4); - if let (Some(v4), Some(m)) = (ip, mac) { - return Some((IpAddr::V4(v4), m)); + ip = ip_from_sockaddr(sa); + if let Some(entry) = make_entry(ip, mac, link_ifindex, scope_id) { + return Some(entry); + } + } + x if x == libc::AF_INET6 => { + if let Some(IpAddr::V6(v6)) = ip_from_sockaddr(sa) { + let sin6 = unsafe { &*(sa as *const _ as *const libc::sockaddr_in6) }; + let (v6, normalized_scope) = normalize_scoped_v6(v6, sin6.sin6_scope_id); + scope_id = normalized_scope; + ip = Some(IpAddr::V6(v6)); + if let Some(entry) = make_entry(ip, mac, link_ifindex, scope_id) { + return Some(entry); } } } x if x == libc::AF_LINK => { - let sdl = unsafe { &*(sa as *const _ as *const libc::sockaddr_dl) }; - let nlen = sdl.sdl_nlen as usize; - let alen = sdl.sdl_alen as usize; - let total = sdl.sdl_len as usize; - - if total >= core::mem::size_of::() - && alen >= 6 - && sa_len >= total - { - let base = sa as *const _ as *const u8; - let data_base = &sdl.sdl_data as *const _ as *const u8; - let data_off = unsafe { data_base.offset_from(base) } as usize; - - if data_off + nlen + alen <= total { - let mac_ptr = unsafe { data_base.add(nlen) }; - let m = MacAddr::from_octets(unsafe { - [ - *mac_ptr.add(0), - *mac_ptr.add(1), - *mac_ptr.add(2), - *mac_ptr.add(3), - *mac_ptr.add(4), - *mac_ptr.add(5), - ] - }); - mac = Some(m); - if let (Some(v4), Some(m)) = (ip, mac) { - return Some((IpAddr::V4(v4), m)); - } + if sa_len < SOCKADDR_DL_HEADER_LEN { + off += roundup(sa_len); + continue; + } + // Only the fixed eight-byte header is required. FreeBSD's libc exposes a + // larger sockaddr_dl backing array than a routing message is required to carry. + let sdl = &msg[off..off + sa_len]; + let nlen = sdl[5] as usize; + let alen = sdl[6] as usize; + link_ifindex = u16::from_ne_bytes([sdl[2], sdl[3]]) as u32; + + let mac_start = SOCKADDR_DL_HEADER_LEN + nlen; + if alen >= 6 && mac_start + alen <= sdl.len() { + let m = MacAddr::from_octets([ + sdl[mac_start], + sdl[mac_start + 1], + sdl[mac_start + 2], + sdl[mac_start + 3], + sdl[mac_start + 4], + sdl[mac_start + 5], + ]); + mac = Some(m); + if let Some(entry) = make_entry(ip, mac, link_ifindex, scope_id) { + return Some(entry); } } } @@ -376,18 +425,41 @@ fn parse_one_route(hdr: &rt_msghdr, addr_block: &[u8]) -> Option { }) } -/// Build an ARP/Neighbor table from the BSD/Darwin routing socket via `sysctl`. -fn get_arp_table() -> io::Result> { - let mut arp_map: HashMap = HashMap::new(); - - let mut mib = [ +fn neighbor_mib(address_family: c_int) -> Vec { + let neighbor_flag = { + #[cfg(any(target_os = "freebsd", target_os = "openbsd"))] + { + RTF_LLINFO + } + #[cfg(target_os = "netbsd")] + { + RTF_LLDATA + } + }; + let mib = vec![ CTL_NET, libc::PF_ROUTE, 0, - libc::AF_INET, + address_family, NET_RT_FLAGS, - RTF_LLINFO as i32, // ARP/neighbor entries + neighbor_flag as c_int, ]; + #[cfg(target_os = "openbsd")] + { + let mut mib = mib; + mib.push(0); // Default routing domain. + mib + } + #[cfg(not(target_os = "openbsd"))] + { + mib + } +} + +/// Read ARP or NDP entries from the BSD routing table via `sysctl`. +fn get_neighbor_entries(address_family: c_int) -> io::Result> { + let mut entries = Vec::new(); + let mut mib = neighbor_mib(address_family); let buf = sysctl_vec(&mut mib)?; let mut off = 0usize; @@ -411,26 +483,64 @@ fn get_arp_table() -> io::Result> { break; } let addr_block = &buf[off + hdrlen..off + msglen]; - if let Some((ip, mac)) = message_to_arppair(addr_block) { - arp_map.insert(ip, mac); + if let Some(entry) = message_to_neighbor(addr_block, hdr.rtm_index as u32) { + entries.push(entry); } off += msglen; } - Ok(arp_map) + Ok(entries) } -/// Dump the routing table via `sysctl` on BSD/Darwin and parse each `rt_msghdr`. -fn list_routes() -> io::Result> { - let mut mib = [ +#[derive(Default)] +struct NeighborTables { + ipv4: HashMap, + ipv6: HashMap<(u32, Ipv6Addr), MacAddr>, +} + +fn get_neighbor_tables() -> NeighborTables { + let mut tables = NeighborTables::default(); + + // Fetch each family independently so failure of one table does not discard the other. + for entry in get_neighbor_entries(libc::AF_INET).unwrap_or_default() { + if let IpAddr::V4(ip) = entry.ip { + tables.ipv4.insert(ip, entry.mac); + } + } + for entry in get_neighbor_entries(libc::AF_INET6).unwrap_or_default() { + if let IpAddr::V6(ip) = entry.ip { + tables.ipv6.insert((entry.ifindex, ip), entry.mac); + } + } + + tables +} + +fn route_dump_mib() -> Vec { + let mib = vec![ CTL_NET, libc::PF_ROUTE, 0, - 0, // all families - NET_RT_DUMP, // dump routes - 0, + 0, // All address families. + NET_RT_DUMP, // Dump routes. + 0, // Flags, or route priority on OpenBSD. ]; + #[cfg(target_os = "openbsd")] + { + let mut mib = mib; + mib.push(0); // Default routing table. + mib + } + #[cfg(not(target_os = "openbsd"))] + { + mib + } +} + +/// Dump the routing table via `sysctl` on BSD and parse each `rt_msghdr`. +fn list_routes() -> io::Result> { + let mut mib = route_dump_mib(); let buf = sysctl_vec(&mut mib)?; let mut out = Vec::::new(); @@ -466,20 +576,27 @@ fn list_routes() -> io::Result> { Ok(out) } -/// Build a map `ifindex -> NetworkDevice` for default gateways on BSD/Darwin. +/// Build a map `ifindex -> NetworkDevice` for default gateways on BSD. pub fn get_gateway_map() -> HashMap { // Fetch routes; on failure just return an empty map. let routes = match list_routes() { Ok(v) => v, Err(_) => return HashMap::new(), }; - // ARP cache: IP -> MAC (empty if ARP cannot be read) - let arp_map = get_arp_table().unwrap_or_default(); + let neighbor_tables = get_neighbor_tables(); + build_gateway_map(routes, &neighbor_tables) +} + +fn build_gateway_map( + routes: Vec, + neighbor_tables: &NeighborTables, +) -> HashMap { // Accumulator: ifindex -> (optional MAC candidate, v4 list, v6 list) #[derive(Default)] struct Acc { - mac: Option, + ipv4_mac: Option, + ipv6_mac: Option, v4: Vec, v6: Vec, } @@ -510,16 +627,17 @@ pub fn get_gateway_map() -> HashMap { let entry = acc.entry(r.ifindex).or_default(); - // If this is an IPv4 gateway and ARP has the MAC, record it. - if let Some(mac) = arp_map.get(&gw).copied() { - entry.mac = Some(mac); - } - match gw { IpAddr::V4(v4) => { + if let Some(mac) = neighbor_tables.ipv4.get(&v4).copied() { + entry.ipv4_mac = Some(mac); + } push_v4(&mut entry.v4, v4); } IpAddr::V6(v6) => { + if let Some(mac) = neighbor_tables.ipv6.get(&(r.ifindex, v6)).copied() { + entry.ipv6_mac = Some(mac); + } push_v6(&mut entry.v6, v6); } } @@ -528,9 +646,8 @@ pub fn get_gateway_map() -> HashMap { // Shape the final output: ifindex -> NetworkDevice let mut out: HashMap = HashMap::new(); for (ifindex, a) in acc { - // If MAC is still unknown, use a zero MAC - // TODO: Implement NDP lookup for IPv6 - let mac = a.mac.unwrap_or_else(|| MacAddr::zero()); + // Keep IPv4 compatibility when the two gateway families resolve differently. + let mac = a.ipv4_mac.or(a.ipv6_mac).unwrap_or_else(MacAddr::zero); out.insert( ifindex, NetworkDevice { @@ -543,3 +660,152 @@ pub fn get_gateway_map() -> HashMap { out } + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_IFINDEX: u32 = 14; + const TEST_MAC: [u8; 6] = [0x54, 0x9b, 0x49, 0x87, 0xe3, 0x48]; + + fn push_sockaddr_in6(buf: &mut Vec, ip: Ipv6Addr, scope_id: u32) { + let sockaddr_len = mem::size_of::(); + let padded_len = roundup(sockaddr_len); + let start = buf.len(); + buf.resize(start + padded_len, 0); + buf[start] = sockaddr_len as u8; + buf[start + 1] = libc::AF_INET6 as u8; + buf[start + 8..start + 24].copy_from_slice(&ip.octets()); + buf[start + 24..start + 28].copy_from_slice(&scope_id.to_ne_bytes()); + } + + fn push_sockaddr_dl(buf: &mut Vec, ifindex: u16, mac: Option<[u8; 6]>) { + let sockaddr_len = SOCKADDR_DL_HEADER_LEN + mac.map_or(0, |m| m.len()); + let padded_len = roundup(sockaddr_len); + let start = buf.len(); + buf.resize(start + padded_len, 0); + buf[start] = sockaddr_len as u8; + buf[start + 1] = libc::AF_LINK as u8; + buf[start + 2..start + 4].copy_from_slice(&ifindex.to_ne_bytes()); + if let Some(mac) = mac { + buf[start + 6] = mac.len() as u8; + buf[start + SOCKADDR_DL_HEADER_LEN..start + sockaddr_len].copy_from_slice(&mac); + } + } + + fn default_route(gateway: IpAddr, ifindex: u32) -> RawRoute { + RawRoute { + dst: match gateway { + IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED), + IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + }, + prefix: 0, + gateway: Some(gateway), + ifindex, + flags: libc::RTF_GATEWAY, + } + } + + #[test] + fn parses_ipv6_ndp_entry() { + let ip: Ipv6Addr = "fe80::1".parse().unwrap(); + let mut message = Vec::new(); + push_sockaddr_in6(&mut message, ip, TEST_IFINDEX); + push_sockaddr_dl(&mut message, TEST_IFINDEX as u16, Some(TEST_MAC)); + + assert_eq!( + message_to_neighbor(&message, 0), + Some(NeighborEntry { + ip: IpAddr::V6(ip), + ifindex: TEST_IFINDEX, + mac: MacAddr::from_octets(TEST_MAC), + }) + ); + } + + #[cfg(any(target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn normalizes_kame_embedded_scope() { + let embedded_scope: Ipv6Addr = "fe80:e::1".parse().unwrap(); + let mut message = Vec::new(); + push_sockaddr_in6(&mut message, embedded_scope, 0); + push_sockaddr_dl(&mut message, 0, Some(TEST_MAC)); + + let entry = message_to_neighbor(&message, 2).unwrap(); + assert_eq!(entry.ip, IpAddr::V6("fe80::1".parse().unwrap())); + assert_eq!(entry.ifindex, TEST_IFINDEX); + } + + #[test] + fn keeps_link_local_neighbors_separate_by_interface() { + let ip: Ipv6Addr = "fe80::1".parse().unwrap(); + let other_mac = MacAddr::from_octets([0, 1, 2, 3, 4, 5]); + let mut tables = NeighborTables::default(); + tables + .ipv6 + .insert((TEST_IFINDEX, ip), MacAddr::from_octets(TEST_MAC)); + tables.ipv6.insert((15, ip), other_mac); + + let gateways = build_gateway_map( + vec![ + default_route(IpAddr::V6(ip), TEST_IFINDEX), + default_route(IpAddr::V6(ip), 15), + ], + &tables, + ); + + assert_eq!( + gateways.get(&TEST_IFINDEX).unwrap().mac_addr, + MacAddr::from_octets(TEST_MAC) + ); + assert_eq!(gateways.get(&15).unwrap().mac_addr, other_mac); + } + + #[test] + fn resolves_ipv6_only_gateway_and_preserves_zero_fallback() { + let resolved_ip: Ipv6Addr = "fe80::1".parse().unwrap(); + let unresolved_ip: Ipv6Addr = "fe80::2".parse().unwrap(); + let mut tables = NeighborTables::default(); + tables + .ipv6 + .insert((TEST_IFINDEX, resolved_ip), MacAddr::from_octets(TEST_MAC)); + + let gateways = build_gateway_map( + vec![ + default_route(IpAddr::V6(resolved_ip), TEST_IFINDEX), + default_route(IpAddr::V6(unresolved_ip), 15), + ], + &tables, + ); + + assert_eq!( + gateways.get(&TEST_IFINDEX).unwrap().mac_addr, + MacAddr::from_octets(TEST_MAC) + ); + assert_eq!(gateways.get(&15).unwrap().mac_addr, MacAddr::zero()); + } + + #[test] + fn prefers_ipv4_mac_when_both_families_resolve() { + let ipv4 = Ipv4Addr::new(192, 168, 10, 1); + let ipv6: Ipv6Addr = "fe80::1".parse().unwrap(); + let ipv4_mac = MacAddr::from_octets(TEST_MAC); + let ipv6_mac = MacAddr::from_octets([0, 1, 2, 3, 4, 5]); + let mut tables = NeighborTables::default(); + tables.ipv4.insert(ipv4, ipv4_mac); + tables.ipv6.insert((TEST_IFINDEX, ipv6), ipv6_mac); + + let gateways = build_gateway_map( + vec![ + default_route(IpAddr::V6(ipv6), TEST_IFINDEX), + default_route(IpAddr::V4(ipv4), TEST_IFINDEX), + ], + &tables, + ); + + let gateway = gateways.get(&TEST_IFINDEX).unwrap(); + assert_eq!(gateway.mac_addr, ipv4_mac); + assert_eq!(gateway.ipv4, vec![ipv4]); + assert_eq!(gateway.ipv6, vec![ipv6]); + } +} diff --git a/src/os/linux/gateway.rs b/src/os/linux/gateway.rs new file mode 100644 index 0000000..208f93b --- /dev/null +++ b/src/os/linux/gateway.rs @@ -0,0 +1,70 @@ +use std::collections::HashMap; + +#[derive(Default)] +struct MacCandidates { + ipv4: Option<[u8; 6]>, + ipv6: Option<[u8; 6]>, +} + +#[derive(Default)] +pub(crate) struct GatewayMacCandidates { + interfaces: HashMap, +} + +impl GatewayMacCandidates { + pub(crate) fn record_ipv4(&mut self, ifindex: u32, mac: [u8; 6]) { + self.interfaces + .entry(ifindex) + .or_default() + .ipv4 + .get_or_insert(mac); + } + + pub(crate) fn record_ipv6(&mut self, ifindex: u32, mac: [u8; 6]) { + self.interfaces + .entry(ifindex) + .or_default() + .ipv6 + .get_or_insert(mac); + } + + pub(crate) fn get(&self, ifindex: u32) -> Option<[u8; 6]> { + let candidates = self.interfaces.get(&ifindex)?; + candidates.ipv4.or(candidates.ipv6) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefers_ipv4_independently_of_neighbor_order() { + let ipv4_mac = [0, 1, 2, 3, 4, 5]; + let ipv6_mac = [6, 7, 8, 9, 10, 11]; + + let mut ipv6_first = GatewayMacCandidates::default(); + ipv6_first.record_ipv6(2, ipv6_mac); + ipv6_first.record_ipv4(2, ipv4_mac); + + let mut ipv4_first = GatewayMacCandidates::default(); + ipv4_first.record_ipv4(2, ipv4_mac); + ipv4_first.record_ipv6(2, ipv6_mac); + + assert_eq!(ipv6_first.get(2), Some(ipv4_mac)); + assert_eq!(ipv4_first.get(2), Some(ipv4_mac)); + } + + #[test] + fn falls_back_to_ipv6_and_keeps_interfaces_separate() { + let ipv6_mac = [6, 7, 8, 9, 10, 11]; + let other_mac = [12, 13, 14, 15, 16, 17]; + let mut candidates = GatewayMacCandidates::default(); + candidates.record_ipv6(2, ipv6_mac); + candidates.record_ipv4(3, other_mac); + + assert_eq!(candidates.get(2), Some(ipv6_mac)); + assert_eq!(candidates.get(3), Some(other_mac)); + assert_eq!(candidates.get(4), None); + } +} diff --git a/src/os/linux/mod.rs b/src/os/linux/mod.rs index aae7336..88b63df 100644 --- a/src/os/linux/mod.rs +++ b/src/os/linux/mod.rs @@ -3,6 +3,8 @@ pub mod arp; mod dhcp; #[cfg(not(target_os = "android"))] pub mod flags; +#[cfg(feature = "gateway")] +pub(crate) mod gateway; #[cfg(not(target_os = "android"))] pub mod interface; pub mod ipv6_addr_flags; diff --git a/src/os/linux/netlink.rs b/src/os/linux/netlink.rs index 3b42584..1034b9b 100644 --- a/src/os/linux/netlink.rs +++ b/src/os/linux/netlink.rs @@ -326,24 +326,31 @@ pub fn collect_routes() -> io::Result> { } } + let mut mac_candidates = super::gateway::GatewayMacCandidates::default(); for n in neighs { let (ip, mac, ifi) = neigh_extract(&n); let ifi = match ifi { Some(i) => i, None => continue, }; - if let Some(row) = m.get_mut(&ifi) { - if let (Some(m6), Some(ip)) = (mac, ip) { - let hit = match ip { - IpAddr::V4(v4) => row.gw_v4.contains(&v4), - IpAddr::V6(v6) => row.gw_v6.contains(&v6), - }; - if hit { - row.mac = Some(m6); - } + let (Some(row), Some(m6), Some(ip)) = (m.get_mut(&ifi), mac, ip) else { + continue; + }; + match ip { + IpAddr::V4(v4) if row.gw_v4.contains(&v4) => { + mac_candidates.record_ipv4(ifi, m6); + } + IpAddr::V6(v6) if row.gw_v6.contains(&v6) => { + mac_candidates.record_ipv6(ifi, m6); } + _ => {} } } + for (ifindex, row) in &mut m { + // Preserve the established IPv4 result when both families use different routers. + row.mac = mac_candidates.get(*ifindex); + } + Ok(m) } From eeeca5aa437e58d6f1a224a875b178160bf31d8a Mon Sep 17 00:00:00 2001 From: shellrow <81893184+shellrow@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:59:16 +0900 Subject: [PATCH 2/3] fix(windows): resolve IPv6 gateway MAC addresses --- src/interface/interface.rs | 5 +- src/os/windows/interface.rs | 294 +++++++++++++++++++++++++++++++----- 2 files changed, 256 insertions(+), 43 deletions(-) diff --git a/src/interface/interface.rs b/src/interface/interface.rs index 13a2671..22a0bca 100644 --- a/src/interface/interface.rs +++ b/src/interface/interface.rs @@ -105,8 +105,9 @@ pub struct Interface { /// Default gateway associated with this interface, when known. /// /// This field is available only with the `gateway` feature. It may be `None` when the - /// interface is not the default route, when the gateway has no link-layer address available, - /// or when the platform cannot resolve gateway information. + /// interface has no gateway addresses or when the platform cannot obtain gateway + /// information. If the gateway addresses are known but the link-layer address cannot be + /// resolved, the device contains a zero MAC address. #[cfg(feature = "gateway")] pub gateway: Option, /// DNS resolver addresses associated with this interface. diff --git a/src/os/windows/interface.rs b/src/os/windows/interface.rs index b93397c..eecf215 100644 --- a/src/os/windows/interface.rs +++ b/src/os/windows/interface.rs @@ -7,7 +7,7 @@ use windows_sys::Win32::NetworkManagement::IpHelper::{ use windows_sys::Win32::NetworkManagement::Ndis::NET_IF_OPER_STATUS_UP; use windows_sys::Win32::Networking::WinSock::{ AF_INET, AF_INET6, AF_UNSPEC, IpDadStateDeprecated, IpDadStateDuplicate, IpDadStateTentative, - IpSuffixOriginRandom, SOCKADDR_INET, SOCKET_ADDRESS, + IpSuffixOriginRandom, SOCKADDR_IN, SOCKADDR_IN6, SOCKADDR_INET, SOCKET_ADDRESS, }; use super::flags; @@ -30,14 +30,16 @@ use std::mem::MaybeUninit; #[cfg(feature = "gateway")] use std::net::Ipv4Addr; #[cfg(feature = "gateway")] -use windows_sys::Win32::NetworkManagement::IpHelper::SendARP; +use windows_sys::Win32::NetworkManagement::IpHelper::{GetIpNetEntry2, MIB_IPNET_ROW2, SendARP}; +#[cfg(feature = "gateway")] +use windows_sys::Win32::NetworkManagement::Ndis::NET_LUID_LH; fn sanitize_u64(val: u64) -> Option { if val == u64::MAX { None } else { Some(val) } } #[cfg(feature = "gateway")] -fn get_mac_through_arp(src_ip: Ipv4Addr, dst_ip: Ipv4Addr) -> MacAddr { +fn get_mac_through_arp(src_ip: Ipv4Addr, dst_ip: Ipv4Addr) -> Option { let src_ip_int = u32::from_ne_bytes(src_ip.octets()); let dst_ip_int = u32::from_ne_bytes(dst_ip.octets()); let mut out_buf_len = 6; @@ -51,31 +53,128 @@ fn get_mac_through_arp(src_ip: Ipv4Addr, dst_ip: Ipv4Addr) -> MacAddr { ) }; if res == NO_ERROR && out_buf_len == 6 { - MacAddr::from_octets(unsafe { target_mac_addr.assume_init() }) + Some(MacAddr::from_octets(unsafe { + target_mac_addr.assume_init() + })) } else { - MacAddr::zero() + None + } +} + +#[derive(Clone, Copy)] +struct ParsedSocketAddress { + ip_addr: IpAddr, + ipv6_scope_id: Option, + sockaddr: SOCKADDR_INET, +} + +// Copy the family-specific value because an IPv4 SOCKET_ADDRESS can be shorter than +// SOCKADDR_INET, while an IPv6 gateway also needs to retain its scope ID. +unsafe fn parse_socket_address(addr: &SOCKET_ADDRESS) -> Option { + if addr.lpSockaddr.is_null() + || addr.iSockaddrLength < std::mem::size_of::().try_into().unwrap() + { + return None; + } + + let family = unsafe { std::ptr::read_unaligned(addr.lpSockaddr.cast::()) }; + match family { + AF_INET + if usize::try_from(addr.iSockaddrLength).ok()? + >= std::mem::size_of::() => + { + let ipv4 = unsafe { std::ptr::read_unaligned(addr.lpSockaddr.cast::()) }; + Some(ParsedSocketAddress { + ip_addr: IpAddr::V4(unsafe { ipv4.sin_addr.S_un.S_addr }.to_ne_bytes().into()), + ipv6_scope_id: None, + sockaddr: SOCKADDR_INET { Ipv4: ipv4 }, + }) + } + AF_INET6 + if usize::try_from(addr.iSockaddrLength).ok()? + >= std::mem::size_of::() => + { + let ipv6 = unsafe { std::ptr::read_unaligned(addr.lpSockaddr.cast::()) }; + Some(ParsedSocketAddress { + ip_addr: IpAddr::V6(unsafe { ipv6.sin6_addr.u.Byte }.into()), + ipv6_scope_id: Some(unsafe { ipv6.Anonymous.sin6_scope_id }), + sockaddr: SOCKADDR_INET { Ipv6: ipv6 }, + }) + } + _ => None, } } -// Convert a socket address into a Rust IpAddr object and also a scope ID if it's an -// IPv6 address unsafe fn socket_address_to_ipaddr(addr: &SOCKET_ADDRESS) -> (Option, Option) { - match unsafe { addr.lpSockaddr.cast::().as_ref() } { + match unsafe { parse_socket_address(addr) } { + Some(parsed) => (Some(parsed.ip_addr), parsed.ipv6_scope_id), None => (None, None), - Some(sockaddr) => match unsafe { sockaddr.si_family } { - AF_INET => { - let addr: IpAddr = unsafe { sockaddr.Ipv4.sin_addr.S_un.S_addr } - .to_ne_bytes() - .into(); - (Some(addr), None) - } - AF_INET6 => { - let addr: IpAddr = unsafe { sockaddr.Ipv6.sin6_addr.u.Byte }.into(); - let scope_id = unsafe { sockaddr.Ipv6.Anonymous.sin6_scope_id }; - (Some(addr), Some(scope_id)) - } - _ => (None, None), - }, + } +} + +#[cfg(feature = "gateway")] +fn physical_address_to_mac(address: &[u8], length: u32) -> Option { + if length != 6 || address.len() < 6 { + return None; + } + Some(MacAddr::from_octets(address[..6].try_into().unwrap())) +} + +#[cfg(feature = "gateway")] +fn get_neighbor_mac(address: SOCKADDR_INET, interface_luid: NET_LUID_LH) -> Option { + let mut row = MIB_IPNET_ROW2 { + Address: address, + InterfaceLuid: interface_luid, + ..Default::default() + }; + let result = unsafe { GetIpNetEntry2(&mut row) }; + if result != NO_ERROR { + return None; + } + physical_address_to_mac(&row.PhysicalAddress, row.PhysicalAddressLength) +} + +#[cfg(feature = "gateway")] +#[derive(Default)] +struct GatewayCandidates { + ipv4: Vec, + ipv6: Vec, + ipv4_mac: Option, + ipv6_mac: Option, +} + +#[cfg(feature = "gateway")] +impl GatewayCandidates { + fn add_ipv4(&mut self, address: Ipv4Addr, mac: Option) { + if !self.ipv4.contains(&address) { + self.ipv4.push(address); + } + if self.ipv4_mac.is_none() { + self.ipv4_mac = mac; + } + } + + fn add_ipv6(&mut self, address: std::net::Ipv6Addr, mac: Option) { + if !self.ipv6.contains(&address) { + self.ipv6.push(address); + } + if self.ipv6_mac.is_none() { + self.ipv6_mac = mac; + } + } + + fn into_device(self) -> Option { + if self.ipv4.is_empty() && self.ipv6.is_empty() { + return None; + } + Some(NetworkDevice { + mac_addr: self + .ipv4_mac + .or(self.ipv6_mac) + .unwrap_or_else(MacAddr::zero), + ipv4: self.ipv4, + ipv6: self.ipv6, + }) } } @@ -219,30 +318,33 @@ pub fn interfaces() -> Vec { } // Gateway #[cfg(feature = "gateway")] - let gateway_ips: Vec = unsafe { linked_list_iter!(&cur.FirstGatewayAddress) } - .filter_map(|cur_g| unsafe { socket_address_to_ipaddr(&cur_g.Address).0 }) - .collect(); + let gateway_addresses: Vec = + unsafe { linked_list_iter!(&cur.FirstGatewayAddress) } + .filter_map(|cur_g| unsafe { parse_socket_address(&cur_g.Address) }) + .collect(); #[cfg(feature = "gateway")] - let mut default_gateway: NetworkDevice = NetworkDevice::new(); + let mut gateway_candidates = GatewayCandidates::default(); #[cfg(feature = "gateway")] if flags & flags::IFF_UP != 0 { - for gateway_ip in gateway_ips { - match gateway_ip { + for gateway in gateway_addresses { + let neighbor_mac = get_neighbor_mac(gateway.sockaddr, cur.Luid); + match gateway.ip_addr { IpAddr::V4(ipv4) => { - if let Some(ip_net) = ipv4_vec.first() { - let mac_addr = get_mac_through_arp(ip_net.addr(), ipv4); - default_gateway.mac_addr = mac_addr; - default_gateway.ipv4.push(ipv4); - } + let mac = neighbor_mac.or_else(|| { + ipv4_vec + .first() + .and_then(|source| get_mac_through_arp(source.addr(), ipv4)) + }); + gateway_candidates.add_ipv4(ipv4, mac); } IpAddr::V6(ipv6) => { - if !ipv6_vec.is_empty() { - default_gateway.ipv6.push(ipv6); - } + gateway_candidates.add_ipv6(ipv6, neighbor_mac); } } } } + #[cfg(feature = "gateway")] + let default_gateway = gateway_candidates.into_device(); // DNS Servers #[cfg(feature = "gateway")] let dns_servers: Vec = unsafe { linked_list_iter!(&cur.FirstDnsServerAddress) } @@ -276,11 +378,7 @@ pub fn interfaces() -> Vec { dhcp_v6_enabled: None, stats, #[cfg(feature = "gateway")] - gateway: if default_gateway.mac_addr == MacAddr::zero() { - None - } else { - Some(default_gateway) - }, + gateway: default_gateway, #[cfg(feature = "gateway")] dns_servers, mtu: Some(cur.Mtu), @@ -291,3 +389,117 @@ pub fn interfaces() -> Vec { }) .collect() } + +#[cfg(all(test, feature = "gateway"))] +mod tests { + use super::*; + use std::net::{Ipv4Addr, Ipv6Addr}; + use windows_sys::Win32::Networking::WinSock::{IN6_ADDR, SOCKADDR, SOCKADDR_IN6_0}; + + const IPV4_MAC: [u8; 6] = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55]; + const IPV6_MAC: [u8; 6] = [0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb]; + + #[test] + fn builds_ipv6_only_gateway_with_resolved_mac() { + let address = "fe80::1".parse().unwrap(); + let mac = MacAddr::from_octets(IPV6_MAC); + let mut candidates = GatewayCandidates::default(); + candidates.add_ipv6(address, Some(mac)); + + let gateway = candidates.into_device().unwrap(); + assert_eq!(gateway.mac_addr, mac); + assert!(gateway.ipv4.is_empty()); + assert_eq!(gateway.ipv6, vec![address]); + } + + #[test] + fn preserves_unresolved_ipv6_only_gateway() { + let address = "fe80::1".parse().unwrap(); + let mut candidates = GatewayCandidates::default(); + candidates.add_ipv6(address, None); + + let gateway = candidates.into_device().unwrap(); + assert_eq!(gateway.mac_addr, MacAddr::zero()); + assert!(gateway.ipv4.is_empty()); + assert_eq!(gateway.ipv6, vec![address]); + } + + #[test] + fn prefers_ipv4_mac_regardless_of_enumeration_order() { + let ipv4 = Ipv4Addr::new(192, 0, 2, 1); + let ipv6 = "fe80::1".parse().unwrap(); + let ipv4_mac = MacAddr::from_octets(IPV4_MAC); + let ipv6_mac = MacAddr::from_octets(IPV6_MAC); + let mut candidates = GatewayCandidates::default(); + candidates.add_ipv6(ipv6, Some(ipv6_mac)); + candidates.add_ipv4(ipv4, Some(ipv4_mac)); + + let gateway = candidates.into_device().unwrap(); + assert_eq!(gateway.mac_addr, ipv4_mac); + assert_eq!(gateway.ipv4, vec![ipv4]); + assert_eq!(gateway.ipv6, vec![ipv6]); + } + + #[test] + fn builds_ipv4_only_gateway() { + let address = Ipv4Addr::new(192, 0, 2, 1); + let mac = MacAddr::from_octets(IPV4_MAC); + let mut candidates = GatewayCandidates::default(); + candidates.add_ipv4(address, Some(mac)); + + let gateway = candidates.into_device().unwrap(); + assert_eq!(gateway.mac_addr, mac); + assert_eq!(gateway.ipv4, vec![address]); + assert!(gateway.ipv6.is_empty()); + } + + #[test] + fn deduplicates_gateway_addresses() { + let ipv4 = Ipv4Addr::new(192, 0, 2, 1); + let ipv6 = "fe80::1".parse().unwrap(); + let mut candidates = GatewayCandidates::default(); + candidates.add_ipv4(ipv4, None); + candidates.add_ipv4(ipv4, None); + candidates.add_ipv6(ipv6, None); + candidates.add_ipv6(ipv6, None); + + let gateway = candidates.into_device().unwrap(); + assert_eq!(gateway.ipv4, vec![ipv4]); + assert_eq!(gateway.ipv6, vec![ipv6]); + } + + #[test] + fn rejects_invalid_physical_address_lengths() { + assert_eq!(physical_address_to_mac(&IPV4_MAC, 5), None); + assert_eq!(physical_address_to_mac(&IPV4_MAC, 7), None); + assert_eq!(physical_address_to_mac(&IPV4_MAC[..5], 6), None); + assert_eq!( + physical_address_to_mac(&IPV4_MAC, 6), + Some(MacAddr::from_octets(IPV4_MAC)) + ); + } + + #[test] + fn retains_scoped_link_local_ipv6_sockaddr() { + let address: Ipv6Addr = "fe80::1".parse().unwrap(); + let mut sockaddr = SOCKADDR_IN6 { + sin6_family: AF_INET6, + sin6_addr: IN6_ADDR { + u: windows_sys::Win32::Networking::WinSock::IN6_ADDR_0 { + Byte: address.octets(), + }, + }, + Anonymous: SOCKADDR_IN6_0 { sin6_scope_id: 17 }, + ..Default::default() + }; + let socket_address = SOCKET_ADDRESS { + lpSockaddr: (&mut sockaddr as *mut SOCKADDR_IN6).cast::(), + iSockaddrLength: std::mem::size_of::() as i32, + }; + + let parsed = unsafe { parse_socket_address(&socket_address) }.unwrap(); + assert_eq!(parsed.ip_addr, IpAddr::V6(address)); + assert_eq!(parsed.ipv6_scope_id, Some(17)); + assert_eq!(unsafe { parsed.sockaddr.Ipv6.Anonymous.sin6_scope_id }, 17); + } +} From 238078151a88c5d77e974a85acff8333795c2060 Mon Sep 17 00:00:00 2001 From: shellrow <81893184+shellrow@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:53:08 +0900 Subject: [PATCH 3/3] fix(openbsd): correct ifmedia subtype types --- src/os/unix/link_speed.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/os/unix/link_speed.rs b/src/os/unix/link_speed.rs index 2d0bfbf..91a2023 100644 --- a/src/os/unix/link_speed.rs +++ b/src/os/unix/link_speed.rs @@ -538,8 +538,8 @@ mod openbsd_subtypes { const IFM_TMASK: u64 = 0x00000000000000ff; - pub(crate) fn ifm_subtype(x: i32) -> u64 { - (x as u64) & IFM_TMASK + pub(crate) fn ifm_subtype(x: u64) -> i32 { + (x & IFM_TMASK) as i32 } const IFM_10G_LR: i32 = 18; // 10GBase-LR - single-mode fiber @@ -633,6 +633,16 @@ mod openbsd_subtypes { )), } } + + #[cfg(test)] + mod tests { + use super::ifm_subtype; + + #[test] + fn extracts_subtype_from_openbsd_media_word() { + assert_eq!(ifm_subtype(0xffff_ffff_ffff_ff10), 0x10); + } + } } #[cfg(target_os = "netbsd")]