From 0d75aa340675877b75c937a66d70e0297aa8bd6f Mon Sep 17 00:00:00 2001 From: Frando Date: Mon, 17 Aug 2026 17:08:23 +0200 Subject: [PATCH 01/10] feat: add netwatch-netlink, a minimal rtnetlink client crate netlink-packet-route has no feature flags and adds ~300kb to every linux binary while netwatch parses a handful of attributes from three message families. This crate covers exactly that subset: link, address and route dumps, a link-by-index lookup, and a multicast event socket, over a plain non-blocking netlink socket. netwatch will switch to it and drop netlink-proto, netlink-sys, netlink-packet-core and netlink-packet-route. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 10 + Cargo.toml | 3 +- netwatch-netlink/Cargo.toml | 30 ++ netwatch-netlink/README.md | 24 ++ netwatch-netlink/src/conn.rs | 569 +++++++++++++++++++++++++++++++ netwatch-netlink/src/lib.rs | 71 ++++ netwatch-netlink/src/message.rs | 578 ++++++++++++++++++++++++++++++++ netwatch-netlink/src/wire.rs | 148 ++++++++ 8 files changed, 1432 insertions(+), 1 deletion(-) create mode 100644 netwatch-netlink/Cargo.toml create mode 100644 netwatch-netlink/README.md create mode 100644 netwatch-netlink/src/conn.rs create mode 100644 netwatch-netlink/src/lib.rs create mode 100644 netwatch-netlink/src/message.rs create mode 100644 netwatch-netlink/src/wire.rs diff --git a/Cargo.lock b/Cargo.lock index 79485a5c..26ef46ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1198,6 +1198,16 @@ dependencies = [ "wmi", ] +[[package]] +name = "netwatch-netlink" +version = "0.1.0" +dependencies = [ + "libc", + "n0-error", + "tokio", + "tracing", +] + [[package]] name = "nix" version = "0.30.1" diff --git a/Cargo.toml b/Cargo.toml index 314f124f..da2234c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [workspace] members = [ "portmapper", - "netwatch" + "netwatch", + "netwatch-netlink" ] resolver = "2" diff --git a/netwatch-netlink/Cargo.toml b/netwatch-netlink/Cargo.toml new file mode 100644 index 00000000..1a7acd93 --- /dev/null +++ b/netwatch-netlink/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "netwatch-netlink" +version = "0.1.0" +readme = "README.md" +description = "Minimal rtnetlink client for netwatch" +license = "MIT OR Apache-2.0" +authors = ["n0 team"] +repository = "https://github.com/n0-computer/net-tools" +keywords = ["networking", "netlink"] +edition = "2024" + +# Sadly this also needs to be updated in .github/workflows/ci.yml +rust-version = "1.91" + +[lints] +workspace = true + +# The crate is empty on every other platform (see lib.rs), so all +# dependencies are gated on the platforms with rtnetlink. +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] +libc = "0.2.139" +n0-error = "1.0.0" +tokio = { version = "1", features = ["net", "time"] } +tracing = "0.1" + +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dev-dependencies] +tokio = { version = "1", features = ["macros", "rt", "time"] } + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu", "aarch64-linux-android"] diff --git a/netwatch-netlink/README.md b/netwatch-netlink/README.md new file mode 100644 index 00000000..390f17af --- /dev/null +++ b/netwatch-netlink/README.md @@ -0,0 +1,24 @@ +# netwatch-netlink + +Minimal rtnetlink client used by [netwatch](https://crates.io/crates/netwatch) +on Linux and Android. + +It covers exactly the subset of the netlink route protocol that netwatch +needs: dumping links, addresses and routes, looking up a link by index, and +listening to rtnetlink multicast groups for change events. It is not a +general-purpose netlink library; if you need one, use the +[rust-netlink](https://github.com/rust-netlink) crates instead. + +On every platform other than Linux and Android the crate compiles to +nothing. + +# License + +This project is licensed under either of + + * Apache License, Version 2.0, ([LICENSE-APACHE](../LICENSE-APACHE) or + http://www.apache.org/licenses/LICENSE-2.0) + * MIT license ([LICENSE-MIT](../LICENSE-MIT) or + http://opensource.org/licenses/MIT) + +at your option. diff --git a/netwatch-netlink/src/conn.rs b/netwatch-netlink/src/conn.rs new file mode 100644 index 00000000..f8e057c5 --- /dev/null +++ b/netwatch-netlink/src/conn.rs @@ -0,0 +1,569 @@ +//! Netlink route sockets: blocking and async dump connections, and the +//! multicast event socket. + +use std::{ + collections::VecDeque, + io, + os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}, + time::{Duration, Instant}, +}; + +use n0_error::e; +use tokio::io::unix::AsyncFd; +use tracing::warn; + +use crate::{ + Error, + message::{ + self, AddressMessage, Frame, LinkMessage, Message, RouteFamily, RouteMessage, + dump_addresses_request, dump_links_request, dump_routes_request, get_link_request, + }, +}; + +/// Receive buffer size for a single datagram. +/// +/// The kernel caps rtnetlink dump datagrams well below this (32k), so a +/// larger buffer only wastes memory. +const RECV_BUF_SIZE: usize = 64 * 1024; + +/// Deadline for a whole dump. +/// +/// When the deadline passes, the messages collected so far are returned: +/// enumeration should degrade rather than fail when the kernel drops a +/// datagram. +const DUMP_TIMEOUT: Duration = Duration::from_secs(2); + +/// A non-blocking `NETLINK_ROUTE` socket. +#[derive(Debug)] +struct NetlinkSocket { + fd: OwnedFd, +} + +impl NetlinkSocket { + /// Opens the socket, subscribed to the multicast groups in `groups` + /// (zero for request/response use). + fn new(groups: u32) -> io::Result { + let fd = unsafe { + libc::socket( + libc::AF_NETLINK, + libc::SOCK_DGRAM | libc::SOCK_CLOEXEC | libc::SOCK_NONBLOCK, + libc::NETLINK_ROUTE, + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: `fd` is a freshly created socket owned by no one else. + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + let socket = Self { fd }; + + // On Android 11+ SELinux denies bind on netlink route sockets for + // apps; the kernel auto-binds on the first send instead. Group + // subscription requires the bind and is not used on Android + // (netmon is a no-op there). + let bind = groups != 0 || cfg!(not(target_os = "android")); + if bind { + // SAFETY: sockaddr_nl is valid when zeroed. + let mut addr: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + addr.nl_family = libc::AF_NETLINK as libc::sa_family_t; + addr.nl_groups = groups; + // SAFETY: `addr` is a valid sockaddr_nl and outlives the call. + let res = unsafe { + libc::bind( + socket.fd.as_raw_fd(), + std::ptr::from_ref(&addr).cast(), + std::mem::size_of::() as libc::socklen_t, + ) + }; + if res < 0 { + return Err(io::Error::last_os_error()); + } + } + Ok(socket) + } + + /// Sends a request datagram to the kernel. + fn send_request(&self, buf: &[u8]) -> io::Result<()> { + // SAFETY: sockaddr_nl is valid when zeroed; pid and groups zero + // address the kernel. + let mut addr: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + addr.nl_family = libc::AF_NETLINK as libc::sa_family_t; + // SAFETY: `buf` and `addr` are valid for the duration of the call. + let res = unsafe { + libc::sendto( + self.fd.as_raw_fd(), + buf.as_ptr().cast(), + buf.len(), + 0, + std::ptr::from_ref(&addr).cast(), + std::mem::size_of::() as libc::socklen_t, + ) + }; + if res < 0 { + return Err(io::Error::last_os_error()); + } + // Datagram sockets send whole messages; a short send cannot happen. + debug_assert_eq!(res as usize, buf.len()); + Ok(()) + } + + /// Receives one datagram. + /// + /// Returns the datagram's true length, which exceeds `buf.len()` when + /// the datagram was truncated (`MSG_TRUNC`). + fn recv(&self, buf: &mut [u8]) -> io::Result { + // SAFETY: `buf` is valid for writes of `buf.len()` bytes. + let res = unsafe { + libc::recv( + self.fd.as_raw_fd(), + buf.as_mut_ptr().cast(), + buf.len(), + libc::MSG_TRUNC, + ) + }; + if res < 0 { + return Err(io::Error::last_os_error()); + } + Ok(res as usize) + } + + /// Waits until the socket is readable or `deadline` passes. + /// + /// Returns `false` on timeout. + fn poll_readable(&self, deadline: Instant) -> io::Result { + loop { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return Ok(false); + }; + let timeout_ms = remaining.as_millis().min(i32::MAX as u128 - 1) as i32 + 1; + let mut pollfd = libc::pollfd { + fd: self.fd.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: `pollfd` is a valid pollfd array of length one. + let res = unsafe { libc::poll(&mut pollfd, 1, timeout_ms) }; + match res { + -1 => { + let err = io::Error::last_os_error(); + if err.kind() != io::ErrorKind::Interrupted { + return Err(err); + } + } + 0 => return Ok(false), + _ => return Ok(true), + } + } + } +} + +impl AsRawFd for NetlinkSocket { + fn as_raw_fd(&self) -> RawFd { + self.fd.as_raw_fd() + } +} + +/// Maps a receive error, turning `ENOBUFS` into [`Error::Overrun`]. +fn map_recv_err(err: io::Error) -> Error { + if err.raw_os_error() == Some(libc::ENOBUFS) { + e!(Error::Overrun) + } else { + e!(Error::Io, err) + } +} + +/// Collects the frames of one dump, keyed by sequence number. +#[derive(Debug, Default)] +struct DumpCollector { + messages: Vec, + done: bool, +} + +impl DumpCollector { + fn push_datagram(&mut self, seq: u32, datagram: &[u8]) -> Result<(), Error> { + for frame in message::parse_frames(datagram) { + match frame { + Frame::Message { seq: s, message } if s == seq => self.messages.push(message), + Frame::Done { seq: s } if s == seq => { + self.done = true; + return Ok(()); + } + Frame::Error { seq: s, code } if s == seq && code != 0 => { + return Err(e!(Error::ErrorMessage { code })); + } + // Acks (code zero), foreign sequence numbers and skipped + // frames are ignored. + _ => {} + } + } + Ok(()) + } +} + +fn filter_links(messages: Vec) -> Vec { + messages + .into_iter() + .filter_map(|message| match message { + Message::NewLink(link) => Some(link), + _ => None, + }) + .collect() +} + +fn filter_addresses(messages: Vec) -> Vec { + messages + .into_iter() + .filter_map(|message| match message { + Message::NewAddress(address) => Some(address), + _ => None, + }) + .collect() +} + +fn filter_routes(messages: Vec) -> Vec { + messages + .into_iter() + .filter_map(|message| match message { + Message::NewRoute(route) => Some(route), + _ => None, + }) + .collect() +} + +/// A blocking request/response connection. +/// +/// Dumps wait at most two seconds and return the messages received so far +/// when the deadline passes. +#[derive(Debug)] +pub struct Connection { + socket: NetlinkSocket, + seq: u32, + buf: Vec, +} + +impl Connection { + /// Opens a new connection. + pub fn new() -> Result { + Ok(Self { + socket: NetlinkSocket::new(0)?, + seq: 0, + buf: vec![0; RECV_BUF_SIZE], + }) + } + + /// Dumps all links. + pub fn dump_links(&mut self) -> Result, Error> { + self.dump(dump_links_request).map(filter_links) + } + + /// Dumps all addresses of both families. + pub fn dump_addresses(&mut self) -> Result, Error> { + self.dump(dump_addresses_request).map(filter_addresses) + } + + /// Dumps the routes of the given family. + pub fn dump_routes(&mut self, family: RouteFamily) -> Result, Error> { + self.dump(|seq| dump_routes_request(seq, family)) + .map(filter_routes) + } + + fn next_seq(&mut self) -> u32 { + self.seq = self.seq.wrapping_add(1); + self.seq + } + + fn dump(&mut self, build: impl FnOnce(u32) -> Vec) -> Result, Error> { + let seq = self.next_seq(); + self.socket.send_request(&build(seq))?; + let deadline = Instant::now() + DUMP_TIMEOUT; + let mut collector = DumpCollector::default(); + while !collector.done { + if !self.socket.poll_readable(deadline)? { + warn!("netlink dump timed out, returning partial result"); + break; + } + match self.socket.recv(&mut self.buf) { + Ok(len) if len > self.buf.len() => return Err(e!(Error::Truncated)), + Ok(len) => collector.push_datagram(seq, &self.buf[..len])?, + Err(err) if err.kind() == io::ErrorKind::WouldBlock => {} + Err(err) => return Err(map_recv_err(err)), + } + } + Ok(collector.messages) + } +} + +/// An async request/response connection. +/// +/// The async twin of [`Connection`], with the same dump semantics. +#[derive(Debug)] +pub struct AsyncConnection { + socket: AsyncFd, + seq: u32, + buf: Vec, +} + +impl AsyncConnection { + /// Opens a new connection. + /// + /// Must be called from within a tokio runtime. + pub fn new() -> Result { + let socket = NetlinkSocket::new(0)?; + Ok(Self { + socket: AsyncFd::new(socket).map_err(|err| e!(Error::Io, err))?, + seq: 0, + buf: vec![0; RECV_BUF_SIZE], + }) + } + + /// Dumps all links. + pub async fn dump_links(&mut self) -> Result, Error> { + self.dump(dump_links_request).await.map(filter_links) + } + + /// Dumps all addresses of both families. + pub async fn dump_addresses(&mut self) -> Result, Error> { + self.dump(dump_addresses_request) + .await + .map(filter_addresses) + } + + /// Dumps the routes of the given family. + pub async fn dump_routes(&mut self, family: RouteFamily) -> Result, Error> { + self.dump(|seq| dump_routes_request(seq, family)) + .await + .map(filter_routes) + } + + /// Requests a single link by interface index. + /// + /// Returns `None` when the kernel does not answer within the dump + /// deadline. + pub async fn get_link_by_index(&mut self, index: u32) -> Result, Error> { + let seq = self.next_seq(); + self.socket + .get_ref() + .send_request(&get_link_request(seq, index))?; + let deadline = tokio::time::Instant::now() + DUMP_TIMEOUT; + loop { + let Some(len) = self.recv_datagram(deadline).await? else { + return Ok(None); + }; + for frame in message::parse_frames(&self.buf[..len]) { + match frame { + Frame::Message { + seq: s, + message: Message::NewLink(link), + } if s == seq => return Ok(Some(link)), + Frame::Error { seq: s, code } if s == seq && code != 0 => { + return Err(e!(Error::ErrorMessage { code })); + } + Frame::Done { seq: s } if s == seq => return Ok(None), + _ => {} + } + } + } + } + + fn next_seq(&mut self) -> u32 { + self.seq = self.seq.wrapping_add(1); + self.seq + } + + async fn dump(&mut self, build: impl FnOnce(u32) -> Vec) -> Result, Error> { + let seq = self.next_seq(); + self.socket.get_ref().send_request(&build(seq))?; + let deadline = tokio::time::Instant::now() + DUMP_TIMEOUT; + let mut collector = DumpCollector::default(); + while !collector.done { + let Some(len) = self.recv_datagram(deadline).await? else { + warn!("netlink dump timed out, returning partial result"); + break; + }; + collector.push_datagram(seq, &self.buf[..len])?; + } + Ok(collector.messages) + } + + /// Receives one datagram into the connection buffer. + /// + /// Returns its length, or `None` when `deadline` passes first. + async fn recv_datagram( + &mut self, + deadline: tokio::time::Instant, + ) -> Result, Error> { + let Self { socket, buf, .. } = self; + loop { + let Ok(guard) = tokio::time::timeout_at(deadline, socket.readable()).await else { + return Ok(None); + }; + let mut guard = guard.map_err(|err| e!(Error::Io, err))?; + match guard.try_io(|socket| socket.get_ref().recv(buf)) { + Ok(Ok(len)) if len > buf.len() => return Err(e!(Error::Truncated)), + Ok(Ok(len)) => return Ok(Some(len)), + Ok(Err(err)) => return Err(map_recv_err(err)), + // Spurious readiness: wait again. + Err(_would_block) => {} + } + } + } +} + +/// A pending event parsed from a received datagram. +#[derive(Debug)] +enum PendingEvent { + Message(Message), + Done, + Error { code: i32 }, +} + +/// A socket subscribed to rtnetlink multicast groups. +/// +/// Build the `groups` mask from `RTNLGRP_*` values with +/// [`group_flag`](crate::group_flag). +#[derive(Debug)] +pub struct EventSocket { + socket: AsyncFd, + buf: Vec, + pending: VecDeque, +} + +impl EventSocket { + /// Subscribes to the multicast groups in the `groups` bind mask. + /// + /// Must be called from within a tokio runtime. + pub fn subscribe(groups: u32) -> Result { + let socket = NetlinkSocket::new(groups)?; + Ok(Self { + socket: AsyncFd::new(socket).map_err(|err| e!(Error::Io, err))?, + buf: vec![0; RECV_BUF_SIZE], + pending: VecDeque::new(), + }) + } + + /// Waits for the next event message. + /// + /// # Errors + /// + /// - [`Error::ErrorMessage`]: the kernel sent an error frame; the + /// socket remains usable and `next` can be called again. + /// - [`Error::Overrun`], [`Error::Done`], [`Error::Truncated`], + /// [`Error::Io`]: events may have been lost; the caller should drop + /// the socket, resubscribe, and re-read the state it watches. + pub async fn next(&mut self) -> Result { + loop { + match self.pending.pop_front() { + Some(PendingEvent::Message(message)) => return Ok(message), + Some(PendingEvent::Done) => return Err(e!(Error::Done)), + Some(PendingEvent::Error { code }) => return Err(e!(Error::ErrorMessage { code })), + None => {} + } + let Self { + socket, + buf, + pending, + } = self; + let mut guard = socket.readable().await.map_err(|err| e!(Error::Io, err))?; + match guard.try_io(|socket| socket.get_ref().recv(buf)) { + Ok(Ok(len)) if len > buf.len() => return Err(e!(Error::Truncated)), + Ok(Ok(len)) => { + for frame in message::parse_frames(&buf[..len]) { + match frame { + Frame::Message { message, .. } => { + pending.push_back(PendingEvent::Message(message)); + } + Frame::Done { .. } => pending.push_back(PendingEvent::Done), + Frame::Error { code, .. } => { + pending.push_back(PendingEvent::Error { code }); + } + Frame::Skip => {} + } + } + } + Ok(Err(err)) => return Err(map_recv_err(err)), + // Spurious readiness: wait again. + Err(_would_block) => {} + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn loopback_link(links: &[LinkMessage]) -> &LinkMessage { + links + .iter() + .find(|link| link.name.as_deref() == Some("lo")) + .expect("no loopback link") + } + + #[test] + fn test_sync_dump_links() { + let mut conn = Connection::new().unwrap(); + let links = conn.dump_links().unwrap(); + let lo = loopback_link(&links); + assert!(lo.index >= 1); + assert_ne!(lo.flags & libc::IFF_LOOPBACK as u32, 0); + } + + #[test] + fn test_sync_dump_addresses() { + let mut conn = Connection::new().unwrap(); + let addresses = conn.dump_addresses().unwrap(); + let localhost = addresses + .iter() + .find(|addr| addr.interface_address() == Some(std::net::Ipv4Addr::LOCALHOST.into())) + .expect("no loopback address"); + assert_eq!(localhost.prefix_len, 8); + assert_eq!(localhost.family as i32, libc::AF_INET); + } + + #[test] + fn test_sync_dump_routes() { + let mut conn = Connection::new().unwrap(); + // The route table may be empty in minimal namespaces; only assert + // that dumping works and parses. + let routes = conn.dump_routes(RouteFamily::Ipv4).unwrap(); + for route in &routes { + assert_eq!(route.family as i32, libc::AF_INET); + } + let _ = conn.dump_routes(RouteFamily::Unspec).unwrap(); + } + + #[tokio::test] + async fn test_async_dumps_and_link_by_index() { + let mut conn = AsyncConnection::new().unwrap(); + let links = conn.dump_links().await.unwrap(); + let lo = loopback_link(&links).clone(); + + let addresses = conn.dump_addresses().await.unwrap(); + assert!(addresses.iter().any(|addr| addr.index == lo.index)); + + let _ = conn.dump_routes(RouteFamily::Ipv6).await.unwrap(); + + let link = conn.get_link_by_index(lo.index).await.unwrap().unwrap(); + assert_eq!(link.name.as_deref(), Some("lo")); + } + + #[tokio::test] + async fn test_get_link_by_index_missing() { + let mut conn = AsyncConnection::new().unwrap(); + let res = conn.get_link_by_index(u32::MAX - 7).await; + assert!(matches!(res, Err(Error::ErrorMessage { .. }))); + } + + #[tokio::test] + async fn test_event_socket_subscribes() { + let groups = crate::group_flag(libc::RTNLGRP_IPV4_IFADDR) + | crate::group_flag(libc::RTNLGRP_IPV6_IFADDR) + | crate::group_flag(libc::RTNLGRP_IPV4_ROUTE) + | crate::group_flag(libc::RTNLGRP_IPV6_ROUTE); + let mut events = EventSocket::subscribe(groups).unwrap(); + // No events are expected in an idle test environment; just make + // sure waiting does not error out immediately. + let next = tokio::time::timeout(Duration::from_millis(50), events.next()).await; + assert!(next.is_err(), "unexpected event: {next:?}"); + } +} diff --git a/netwatch-netlink/src/lib.rs b/netwatch-netlink/src/lib.rs new file mode 100644 index 00000000..3f6ee46c --- /dev/null +++ b/netwatch-netlink/src/lib.rs @@ -0,0 +1,71 @@ +//! Minimal rtnetlink client for netwatch. +//! +//! Covers exactly the subset of the netlink route protocol that netwatch +//! uses on Linux and Android: +//! +//! - dumping links, addresses and routes ([`Connection`] for blocking +//! callers, [`AsyncConnection`] inside tokio), +//! - looking up a single link by interface index, +//! - listening to rtnetlink multicast groups for change events +//! ([`EventSocket`]). +//! +//! Messages are parsed into the small typed structs in this crate +//! ([`LinkMessage`], [`AddressMessage`], [`RouteMessage`]); attributes we do +//! not use are skipped. On every platform other than Linux and Android the +//! crate compiles to nothing. +#![cfg(any(target_os = "linux", target_os = "android"))] + +use n0_error::stack_error; + +mod conn; +mod message; +mod wire; + +pub use self::{ + conn::{AsyncConnection, Connection, EventSocket}, + message::{AddressMessage, LinkMessage, Message, RouteFamily, RouteMessage}, +}; + +/// Errors surfaced by the netlink socket wrappers. +#[stack_error(derive, add_meta, from_sources, std_sources)] +#[non_exhaustive] +pub enum Error { + /// A socket operation failed. + #[error("IO")] + Io { source: std::io::Error }, + /// The kernel replied with an `NLMSG_ERROR` message. + /// + /// `code` is a negative errno value. + #[error("netlink error message: code {code}")] + ErrorMessage { code: i32 }, + /// The kernel dropped events because the socket buffer overran. + /// + /// Subscribers should resynchronize by re-reading the state they care + /// about. + #[error("netlink socket buffer overrun")] + Overrun {}, + /// The kernel signaled the end of a multipart message on an event + /// socket. + /// + /// Subscribers should treat this like a lost connection and + /// resubscribe. + #[error("end of multipart message")] + Done {}, + /// A datagram did not fit the receive buffer and was truncated. + #[error("truncated netlink datagram")] + Truncated {}, +} + +/// Returns the `sockaddr_nl` group mask bit for an rtnetlink multicast +/// group. +/// +/// Only the first 31 groups can be subscribed through the bind mask; all +/// `RTNLGRP_*` groups netwatch uses fall in that range. +/// +/// # Panics +/// +/// Panics when `group` is larger than 31. +pub const fn group_flag(group: u32) -> u32 { + assert!(group <= 31, "group not reachable via the bind mask"); + if group == 0 { 0 } else { 1 << (group - 1) } +} diff --git a/netwatch-netlink/src/message.rs b/netwatch-netlink/src/message.rs new file mode 100644 index 00000000..5e4c70c5 --- /dev/null +++ b/netwatch-netlink/src/message.rs @@ -0,0 +1,578 @@ +//! Typed rtnetlink messages and requests. +//! +//! Only the message families and attributes netwatch consumes are parsed; +//! everything else is preserved as [`Message::Other`] or skipped. + +use std::net::IpAddr; + +use crate::wire::{self, AttrIter}; + +// Attribute types not exposed by the libc crate. +const IFLA_ADDRESS: u16 = 1; +const IFLA_IFNAME: u16 = 3; +const IFA_ADDRESS: u16 = 1; +const IFA_LOCAL: u16 = 2; +const IFA_FLAGS: u16 = 8; +const RTA_DST: u16 = 1; +const RTA_OIF: u16 = 4; +const RTA_GATEWAY: u16 = 5; +const RTA_TABLE: u16 = 15; + +/// Address family selector for route dumps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteFamily { + /// Dump routes of every family. + Unspec, + /// Dump IPv4 routes. + Ipv4, + /// Dump IPv6 routes. + Ipv6, +} + +impl RouteFamily { + fn family(self) -> u8 { + match self { + RouteFamily::Unspec => libc::AF_UNSPEC as u8, + RouteFamily::Ipv4 => libc::AF_INET as u8, + RouteFamily::Ipv6 => libc::AF_INET6 as u8, + } + } +} + +/// A parsed `RTM_NEWLINK` or `RTM_DELLINK` message. +#[derive(Debug, Clone)] +pub struct LinkMessage { + /// Interface index from `ifinfomsg`. + pub index: u32, + /// Interface flags (`IFF_*`) from `ifinfomsg`. + pub flags: u32, + /// Interface name from `IFLA_IFNAME`. + pub name: Option, + /// Hardware address bytes from `IFLA_ADDRESS`. + pub address: Option>, +} + +impl LinkMessage { + /// Parses the message from an `ifinfomsg` payload. + /// + /// Wire layout: family `u8`, pad `u8`, type `u16`, index `i32`, flags + /// `u32`, change mask `u32`, then attributes. + fn parse(data: &[u8]) -> Option { + let index = wire::read_i32(data, 4)? as u32; + let flags = wire::read_u32(data, 8)?; + let mut name = None; + let mut address = None; + for (kind, payload) in AttrIter::new(data.get(16..)?) { + match kind { + IFLA_IFNAME => name = parse_string(payload), + IFLA_ADDRESS => address = Some(payload.to_vec()), + _ => {} + } + } + Some(Self { + index, + flags, + name, + address, + }) + } +} + +/// A parsed `RTM_NEWADDR` or `RTM_DELADDR` message. +#[derive(Debug, Clone)] +pub struct AddressMessage { + /// Address family (`AF_INET` or `AF_INET6`) from `ifaddrmsg`. + pub family: u8, + /// Prefix length of the address. + pub prefix_len: u8, + /// Address scope (`RT_SCOPE_*`) from `ifaddrmsg`. + pub scope: u8, + /// Interface index the address belongs to. + pub index: u32, + /// The address from `IFA_ADDRESS`. + /// + /// For IPv4 this is the peer address on point-to-point links and equal + /// to [`AddressMessage::local`] everywhere else; for IPv6 it is the + /// interface address. + pub address: Option, + /// The local interface address from `IFA_LOCAL`, when present. + pub local: Option, + /// Address flags (`IFA_F_*`) from the `IFA_FLAGS` attribute. + /// + /// `None` when the kernel did not send the attribute; use + /// [`AddressMessage::flags`] for the value with the header fallback + /// applied. + pub flags_attr: Option, + /// Address flags from the `ifaddrmsg` header byte. + /// + /// Truncated to eight bits; superseded by `flags_attr` when present. + pub header_flags: u8, +} + +impl AddressMessage { + /// Parses the message from an `ifaddrmsg` payload. + /// + /// Wire layout: family `u8`, prefixlen `u8`, flags `u8`, scope `u8`, + /// index `u32`, then attributes. + fn parse(data: &[u8]) -> Option { + let family = *data.first()?; + let prefix_len = *data.get(1)?; + let header_flags = *data.get(2)?; + let scope = *data.get(3)?; + let index = wire::read_u32(data, 4)?; + let mut address = None; + let mut local = None; + let mut flags_attr = None; + for (kind, payload) in AttrIter::new(data.get(8..)?) { + match kind { + IFA_ADDRESS => address = parse_ip(family, payload), + IFA_LOCAL => local = parse_ip(family, payload), + IFA_FLAGS => flags_attr = wire::read_u32(payload, 0), + _ => {} + } + } + Some(Self { + family, + prefix_len, + scope, + index, + address, + local, + flags_attr, + header_flags, + }) + } + + /// The address flags (`IFA_F_*`), preferring the 32-bit `IFA_FLAGS` + /// attribute over the truncated header byte. + pub fn flags(&self) -> u32 { + self.flags_attr.unwrap_or(self.header_flags as u32) + } + + /// The interface address, preferring `IFA_LOCAL` over `IFA_ADDRESS`. + /// + /// This matches what `ip addr` shows: for IPv4 the kernel puts the + /// interface address in `IFA_LOCAL` and the (peer) address in + /// `IFA_ADDRESS`; for IPv6 only `IFA_ADDRESS` is sent. + pub fn interface_address(&self) -> Option { + self.local.or(self.address) + } +} + +/// A parsed `RTM_NEWROUTE` or `RTM_DELROUTE` message. +#[derive(Debug, Clone)] +pub struct RouteMessage { + /// Address family (`AF_INET` or `AF_INET6`) from `rtmsg`. + pub family: u8, + /// Prefix length of the destination; zero for default routes. + pub dst_len: u8, + /// Routing table id from the `RTA_TABLE` attribute. + /// + /// `None` when the kernel did not send the attribute. The legacy + /// eight-bit `rtmsg` table byte is intentionally not exposed: netwatch + /// has always used only the attribute. + pub table: Option, + /// Route destination from `RTA_DST`; absent for default routes. + pub destination: Option, + /// Gateway address from `RTA_GATEWAY`. + pub gateway: Option, + /// Output interface index from `RTA_OIF`. + pub oif: Option, +} + +impl RouteMessage { + /// Parses the message from an `rtmsg` payload. + /// + /// Wire layout: family, dst_len, src_len, tos, table, protocol, scope, + /// type (all `u8`), flags `u32`, then attributes. + fn parse(data: &[u8]) -> Option { + let family = *data.first()?; + let dst_len = *data.get(1)?; + let mut table = None; + let mut destination = None; + let mut gateway = None; + let mut oif = None; + for (kind, payload) in AttrIter::new(data.get(12..)?) { + match kind { + RTA_TABLE => table = wire::read_u32(payload, 0), + RTA_DST => destination = parse_ip(family, payload), + RTA_GATEWAY => gateway = parse_ip(family, payload), + RTA_OIF => oif = wire::read_u32(payload, 0), + _ => {} + } + } + Some(Self { + family, + dst_len, + table, + destination, + gateway, + oif, + }) + } +} + +/// A parsed rtnetlink message. +/// +/// Rule messages carry no payload here because netwatch only reacts to +/// their presence. +#[derive(Debug, Clone)] +pub enum Message { + /// A link was added or changed. + NewLink(LinkMessage), + /// A link was removed. + DelLink(LinkMessage), + /// An address was added or changed. + NewAddress(AddressMessage), + /// An address was removed. + DelAddress(AddressMessage), + /// A route was added or changed. + NewRoute(RouteMessage), + /// A route was removed. + DelRoute(RouteMessage), + /// A routing policy rule was added. + NewRule, + /// A routing policy rule was removed. + DelRule, + /// Any other rtnetlink message type. + Other { + /// The `nlmsghdr` message type. + kind: u16, + }, +} + +impl Message { + /// Parses a message payload for the given `nlmsghdr` type. + /// + /// Returns `None` when the payload is too short for its fixed header. + pub(crate) fn parse(kind: u16, payload: &[u8]) -> Option { + let message = match kind { + libc::RTM_NEWLINK => Message::NewLink(LinkMessage::parse(payload)?), + libc::RTM_DELLINK => Message::DelLink(LinkMessage::parse(payload)?), + libc::RTM_NEWADDR => Message::NewAddress(AddressMessage::parse(payload)?), + libc::RTM_DELADDR => Message::DelAddress(AddressMessage::parse(payload)?), + libc::RTM_NEWROUTE => Message::NewRoute(RouteMessage::parse(payload)?), + libc::RTM_DELROUTE => Message::DelRoute(RouteMessage::parse(payload)?), + libc::RTM_NEWRULE => Message::NewRule, + libc::RTM_DELRULE => Message::DelRule, + kind => Message::Other { kind }, + }; + Some(message) + } +} + +/// One frame of a received datagram, before dump or event bookkeeping. +#[derive(Debug)] +pub(crate) enum Frame { + /// An rtnetlink message. + Message { seq: u32, message: Message }, + /// `NLMSG_DONE`, the end of a multipart response. + Done { seq: u32 }, + /// `NLMSG_ERROR`; `code` is zero for acknowledgments. + Error { seq: u32, code: i32 }, + /// A frame to ignore (noop, overrun or malformed). + Skip, +} + +/// Splits a received datagram into its netlink frames. +/// +/// Iteration stops at the first frame whose length field is inconsistent +/// with the remaining data. +pub(crate) fn parse_frames(datagram: &[u8]) -> impl Iterator + '_ { + let mut data = datagram; + std::iter::from_fn(move || { + let header = wire::Header::parse(data)?; + let len = header.len as usize; + if len < wire::HEADER_LEN || len > data.len() { + return None; + } + let payload = &data[wire::HEADER_LEN..len]; + data = data.get(wire::align(len)..).unwrap_or_default(); + let frame = match header.kind as i32 { + libc::NLMSG_DONE => Frame::Done { seq: header.seq }, + libc::NLMSG_ERROR => match wire::read_i32(payload, 0) { + Some(code) => Frame::Error { + seq: header.seq, + code, + }, + None => Frame::Skip, + }, + libc::NLMSG_NOOP | libc::NLMSG_OVERRUN => Frame::Skip, + _ => match Message::parse(header.kind, payload) { + Some(message) => Frame::Message { + seq: header.seq, + message, + }, + None => Frame::Skip, + }, + }; + Some(frame) + }) +} + +/// Builds an `RTM_GETLINK` dump request. +pub(crate) fn dump_links_request(seq: u32) -> Vec { + dump_request(libc::RTM_GETLINK, seq, &[0u8; 16]) +} + +/// Builds an `RTM_GETADDR` dump request covering both address families. +pub(crate) fn dump_addresses_request(seq: u32) -> Vec { + dump_request(libc::RTM_GETADDR, seq, &[0u8; 8]) +} + +/// Builds an `RTM_GETROUTE` dump request. +/// +/// The filter fields (main table, static protocol, unicast type) match what +/// netwatch has always sent; the kernel ignores them for non-strict dumps +/// and filters by family only. +pub(crate) fn dump_routes_request(seq: u32, family: RouteFamily) -> Vec { + let mut rtmsg = [0u8; 12]; + rtmsg[0] = family.family(); + rtmsg[4] = libc::RT_TABLE_MAIN; + rtmsg[5] = libc::RTPROT_STATIC; + rtmsg[6] = libc::RT_SCOPE_UNIVERSE; + rtmsg[7] = libc::RTN_UNICAST; + dump_request(libc::RTM_GETROUTE, seq, &rtmsg) +} + +/// Builds an `RTM_GETLINK` request for a single interface index. +pub(crate) fn get_link_request(seq: u32, index: u32) -> Vec { + let mut ifinfomsg = [0u8; 16]; + ifinfomsg[4..8].copy_from_slice(&(index as i32).to_ne_bytes()); + let mut buf = Vec::with_capacity(wire::HEADER_LEN + ifinfomsg.len()); + wire::push_header( + &mut buf, + libc::RTM_GETLINK, + libc::NLM_F_REQUEST as u16, + seq, + ifinfomsg.len(), + ); + buf.extend_from_slice(&ifinfomsg); + buf +} + +fn dump_request(kind: u16, seq: u32, fixed_header: &[u8]) -> Vec { + let mut buf = Vec::with_capacity(wire::HEADER_LEN + fixed_header.len()); + let flags = (libc::NLM_F_REQUEST | libc::NLM_F_DUMP) as u16; + wire::push_header(&mut buf, kind, flags, seq, fixed_header.len()); + buf.extend_from_slice(fixed_header); + buf +} + +/// Parses a NUL-terminated attribute payload into a string. +fn parse_string(payload: &[u8]) -> Option { + let end = payload + .iter() + .position(|&b| b == 0) + .unwrap_or(payload.len()); + String::from_utf8(payload[..end].to_vec()).ok() +} + +/// Parses an address attribute payload according to the message family. +fn parse_ip(family: u8, payload: &[u8]) -> Option { + if family as i32 == libc::AF_INET { + let bytes: [u8; 4] = payload.get(..4)?.try_into().ok()?; + Some(IpAddr::from(bytes)) + } else if family as i32 == libc::AF_INET6 { + let bytes: [u8; 16] = payload.get(..16)?.try_into().ok()?; + Some(IpAddr::from(bytes)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use super::*; + use crate::wire; + + /// Builds a response frame the way the kernel would. + fn build_frame(kind: u16, seq: u32, payload: &[u8]) -> Vec { + let mut buf = Vec::new(); + wire::push_header(&mut buf, kind, libc::NLM_F_MULTI as u16, seq, payload.len()); + buf.extend_from_slice(payload); + buf.resize(wire::align(buf.len()), 0); + buf + } + + fn push_attr(buf: &mut Vec, kind: u16, payload: &[u8]) { + let len = (4 + payload.len()) as u16; + buf.extend_from_slice(&len.to_ne_bytes()); + buf.extend_from_slice(&kind.to_ne_bytes()); + buf.extend_from_slice(payload); + buf.resize(wire::align(buf.len()), 0); + } + + #[test] + fn test_parse_link_message() { + let mut payload = vec![0u8; 16]; + payload[4..8].copy_from_slice(&2i32.to_ne_bytes()); + payload[8..12].copy_from_slice(&0x1003u32.to_ne_bytes()); + push_attr(&mut payload, IFLA_IFNAME, b"eth0\0"); + push_attr(&mut payload, IFLA_ADDRESS, &[1, 2, 3, 4, 5, 6]); + + let datagram = build_frame(libc::RTM_NEWLINK, 1, &payload); + let frames: Vec<_> = parse_frames(&datagram).collect(); + assert_eq!(frames.len(), 1); + let Frame::Message { + seq: 1, + message: Message::NewLink(link), + } = &frames[0] + else { + panic!("expected NewLink, got {:?}", frames[0]); + }; + assert_eq!(link.index, 2); + assert_eq!(link.flags, 0x1003); + assert_eq!(link.name.as_deref(), Some("eth0")); + assert_eq!(link.address.as_deref(), Some(&[1, 2, 3, 4, 5, 6][..])); + } + + #[test] + fn test_parse_address_message_v4() { + let mut payload = vec![0u8; 8]; + payload[0] = libc::AF_INET as u8; + payload[1] = 24; // prefix len + payload[2] = 0x80; // header flags: IFA_F_PERMANENT + payload[4..8].copy_from_slice(&3u32.to_ne_bytes()); + push_attr(&mut payload, IFA_ADDRESS, &[192, 168, 0, 255]); + push_attr(&mut payload, IFA_LOCAL, &[192, 168, 0, 1]); + + let datagram = build_frame(libc::RTM_NEWADDR, 2, &payload); + let Some(Frame::Message { + message: Message::NewAddress(addr), + .. + }) = parse_frames(&datagram).next() + else { + panic!("expected NewAddress"); + }; + assert_eq!(addr.prefix_len, 24); + assert_eq!(addr.index, 3); + assert_eq!( + addr.address, + Some(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 255))) + ); + assert_eq!(addr.local, Some(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)))); + assert_eq!( + addr.interface_address(), + Some(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1))) + ); + // No IFA_FLAGS attribute: the header byte is used. + assert_eq!(addr.flags(), 0x80); + } + + #[test] + fn test_parse_address_message_v6_flags_attr() { + let ip = Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1); + let mut payload = vec![0u8; 8]; + payload[0] = libc::AF_INET6 as u8; + payload[1] = 64; + payload[2] = 0x80; + payload[4..8].copy_from_slice(&5u32.to_ne_bytes()); + push_attr(&mut payload, IFA_ADDRESS, &ip.octets()); + push_attr(&mut payload, IFA_FLAGS, &0x81u32.to_ne_bytes()); + + let datagram = build_frame(libc::RTM_NEWADDR, 2, &payload); + let Some(Frame::Message { + message: Message::NewAddress(addr), + .. + }) = parse_frames(&datagram).next() + else { + panic!("expected NewAddress"); + }; + assert_eq!(addr.address, Some(IpAddr::V6(ip))); + assert_eq!(addr.interface_address(), Some(IpAddr::V6(ip))); + // IFA_FLAGS wins over the header byte. + assert_eq!(addr.flags(), 0x81); + } + + #[test] + fn test_parse_route_message() { + let mut payload = vec![0u8; 12]; + payload[0] = libc::AF_INET as u8; + payload[1] = 0; // default route + push_attr(&mut payload, RTA_TABLE, &254u32.to_ne_bytes()); + push_attr(&mut payload, RTA_GATEWAY, &[10, 0, 0, 1]); + push_attr(&mut payload, RTA_OIF, &2u32.to_ne_bytes()); + + let datagram = build_frame(libc::RTM_NEWROUTE, 3, &payload); + let Some(Frame::Message { + message: Message::NewRoute(route), + .. + }) = parse_frames(&datagram).next() + else { + panic!("expected NewRoute"); + }; + assert_eq!(route.dst_len, 0); + assert_eq!(route.table, Some(254)); + assert_eq!(route.gateway, Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))); + assert_eq!(route.oif, Some(2)); + assert_eq!(route.destination, None); + } + + #[test] + fn test_parse_multipart_with_done() { + let mut payload = vec![0u8; 12]; + payload[0] = libc::AF_INET as u8; + let mut datagram = build_frame(libc::RTM_NEWROUTE, 4, &payload); + datagram.extend_from_slice(&build_frame( + libc::NLMSG_DONE as u16, + 4, + &0i32.to_ne_bytes(), + )); + + let frames: Vec<_> = parse_frames(&datagram).collect(); + assert_eq!(frames.len(), 2); + assert!(matches!(frames[0], Frame::Message { seq: 4, .. })); + assert!(matches!(frames[1], Frame::Done { seq: 4 })); + } + + #[test] + fn test_parse_error_frame() { + let mut payload = Vec::new(); + payload.extend_from_slice(&(-13i32).to_ne_bytes()); + payload.extend_from_slice(&[0u8; 16]); // echoed request header + let datagram = build_frame(libc::NLMSG_ERROR as u16, 5, &payload); + let frames: Vec<_> = parse_frames(&datagram).collect(); + assert!(matches!(frames[0], Frame::Error { seq: 5, code: -13 })); + } + + #[test] + fn test_parse_rule_messages() { + let datagram = build_frame(libc::RTM_NEWRULE, 6, &[0u8; 12]); + let Some(Frame::Message { message, .. }) = parse_frames(&datagram).next() else { + panic!("expected message"); + }; + assert!(matches!(message, Message::NewRule)); + } + + #[test] + fn test_requests_have_valid_headers() { + for (buf, kind, payload_len) in [ + (dump_links_request(1), libc::RTM_GETLINK, 16), + (dump_addresses_request(2), libc::RTM_GETADDR, 8), + ( + dump_routes_request(3, RouteFamily::Ipv4), + libc::RTM_GETROUTE, + 12, + ), + (get_link_request(4, 7), libc::RTM_GETLINK, 16), + ] { + let header = wire::Header::parse(&buf).unwrap(); + assert_eq!(header.kind, kind); + assert_eq!(header.len as usize, buf.len()); + assert_eq!(buf.len(), wire::HEADER_LEN + payload_len); + let flags = wire::read_u16(&buf, 6).unwrap(); + assert_ne!(flags & libc::NLM_F_REQUEST as u16, 0); + } + } + + #[test] + fn test_route_family_bytes() { + let buf = dump_routes_request(1, RouteFamily::Ipv6); + assert_eq!(buf[wire::HEADER_LEN], libc::AF_INET6 as u8); + let buf = dump_routes_request(1, RouteFamily::Unspec); + assert_eq!(buf[wire::HEADER_LEN], 0); + } +} diff --git a/netwatch-netlink/src/wire.rs b/netwatch-netlink/src/wire.rs new file mode 100644 index 00000000..87ec0958 --- /dev/null +++ b/netwatch-netlink/src/wire.rs @@ -0,0 +1,148 @@ +//! Netlink wire format primitives. +//! +//! Netlink messages are native endian and 4-byte aligned: a 16-byte +//! `nlmsghdr`, a fixed per-family header, then a list of attributes, each a +//! 4-byte `rtattr` header (length including the header, then type) followed +//! by the padded payload. + +/// Length of `nlmsghdr`. +pub(crate) const HEADER_LEN: usize = 16; + +/// Rounds `len` up to the 4-byte netlink alignment. +pub(crate) const fn align(len: usize) -> usize { + (len + 3) & !3 +} + +/// A parsed `nlmsghdr`. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Header { + pub(crate) len: u32, + pub(crate) kind: u16, + pub(crate) seq: u32, +} + +impl Header { + /// Parses the header from the start of `data`. + pub(crate) fn parse(data: &[u8]) -> Option { + if data.len() < HEADER_LEN { + return None; + } + Some(Self { + len: read_u32(data, 0)?, + kind: read_u16(data, 4)?, + // offset 6: flags, only meaningful in requests + seq: read_u32(data, 8)?, + // offset 12: pid, unused + }) + } +} + +/// Appends an `nlmsghdr` for a request whose payload is `payload_len` bytes. +pub(crate) fn push_header(buf: &mut Vec, kind: u16, flags: u16, seq: u32, payload_len: usize) { + let len = (HEADER_LEN + payload_len) as u32; + buf.extend_from_slice(&len.to_ne_bytes()); + buf.extend_from_slice(&kind.to_ne_bytes()); + buf.extend_from_slice(&flags.to_ne_bytes()); + buf.extend_from_slice(&seq.to_ne_bytes()); + buf.extend_from_slice(&0u32.to_ne_bytes()); // pid: kernel fills in the sender +} + +/// Iterator over the attributes of a message payload. +/// +/// Yields `(type, payload)` pairs and stops at the first malformed +/// attribute. +pub(crate) struct AttrIter<'a> { + data: &'a [u8], +} + +impl<'a> AttrIter<'a> { + pub(crate) fn new(data: &'a [u8]) -> Self { + Self { data } + } +} + +impl<'a> Iterator for AttrIter<'a> { + type Item = (u16, &'a [u8]); + + fn next(&mut self) -> Option { + let len = read_u16(self.data, 0)? as usize; + let kind = read_u16(self.data, 2)?; + if len < 4 || len > self.data.len() { + return None; + } + let payload = &self.data[4..len]; + self.data = self.data.get(align(len)..).unwrap_or_default(); + Some((kind, payload)) + } +} + +pub(crate) fn read_u16(data: &[u8], offset: usize) -> Option { + let bytes = data.get(offset..offset + 2)?; + Some(u16::from_ne_bytes( + bytes.try_into().expect("length checked"), + )) +} + +pub(crate) fn read_u32(data: &[u8], offset: usize) -> Option { + let bytes = data.get(offset..offset + 4)?; + Some(u32::from_ne_bytes( + bytes.try_into().expect("length checked"), + )) +} + +pub(crate) fn read_i32(data: &[u8], offset: usize) -> Option { + read_u32(data, offset).map(|v| v as i32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_align() { + assert_eq!(align(0), 0); + assert_eq!(align(1), 4); + assert_eq!(align(4), 4); + assert_eq!(align(5), 8); + } + + #[test] + fn test_header_roundtrip() { + let mut buf = Vec::new(); + push_header(&mut buf, 18, 0x301, 7, 16); + let header = Header::parse(&buf).unwrap(); + assert_eq!(header.len, 32); + assert_eq!(header.kind, 18); + assert_eq!(read_u16(&buf, 6), Some(0x301)); + assert_eq!(header.seq, 7); + } + + #[test] + fn test_attr_iter() { + let mut data = Vec::new(); + // attr 1: type 3, payload "lo\0" (len 7, padded to 8) + data.extend_from_slice(&7u16.to_ne_bytes()); + data.extend_from_slice(&3u16.to_ne_bytes()); + data.extend_from_slice(b"lo\0\0"); + // attr 2: type 4, payload u32 + data.extend_from_slice(&8u16.to_ne_bytes()); + data.extend_from_slice(&4u16.to_ne_bytes()); + data.extend_from_slice(&1500u32.to_ne_bytes()); + + let attrs: Vec<_> = AttrIter::new(&data).collect(); + assert_eq!(attrs.len(), 2); + assert_eq!(attrs[0], (3, &b"lo\0"[..])); + assert_eq!(attrs[1].0, 4); + assert_eq!(read_u32(attrs[1].1, 0), Some(1500)); + } + + #[test] + fn test_attr_iter_stops_on_malformed() { + // Claims 12 bytes but only 8 are present. + let mut data = Vec::new(); + data.extend_from_slice(&12u16.to_ne_bytes()); + data.extend_from_slice(&1u16.to_ne_bytes()); + data.extend_from_slice(&0u32.to_ne_bytes()); + assert_eq!(AttrIter::new(&data).count(), 0); + } +} From e600ab9809a156b30c25ff5cf1f78aafcfbcac90 Mon Sep 17 00:00:00 2001 From: Frando Date: Mon, 17 Aug 2026 17:12:02 +0200 Subject: [PATCH 02/10] refactor: port netwatch's netlink users to netwatch-netlink netmon's route monitor and the linux default-route lookup now use the netwatch-netlink event socket and dumps instead of netlink-proto with netlink-packet-route. Same multicast groups, same event filtering, and the same reconnect-with-backoff loop; the netlink-proto dependency and its futures stack leave the tree entirely (netdev still pulls the netlink-packet crates until enumeration moves too). Co-Authored-By: Claude Fable 5 --- Cargo.lock | 5 +- netwatch/Cargo.toml | 5 +- netwatch/src/interfaces/linux.rs | 159 ++++-------------------- netwatch/src/netmon/linux.rs | 205 ++++++++++++------------------- 4 files changed, 102 insertions(+), 272 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26ef46ef..def5fa0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1173,10 +1173,7 @@ dependencies = [ "n0-future", "n0-watcher", "netdev", - "netlink-packet-core", - "netlink-packet-route 0.31.0", - "netlink-proto", - "netlink-sys", + "netwatch-netlink", "noq-udp", "objc2-core-foundation", "objc2-system-configuration", diff --git a/netwatch/Cargo.toml b/netwatch/Cargo.toml index 17b43ff9..3a25d513 100644 --- a/netwatch/Cargo.toml +++ b/netwatch/Cargo.toml @@ -55,10 +55,7 @@ objc2-core-foundation = "0.3.2" objc2-system-configuration = "0.3.2" [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] -netlink-packet-route = "0.31.0" -netlink-packet-core = "0.8.1" -netlink-proto = "0.12.0" -netlink-sys = "0.8.7" +netwatch-netlink = { version = "0.1.0", path = "../netwatch-netlink" } [target.'cfg(target_os = "android")'.dependencies] tokio = { version = "1", features = ["process"] } diff --git a/netwatch/src/interfaces/linux.rs b/netwatch/src/interfaces/linux.rs index 8b6bbff9..641f7b69 100644 --- a/netwatch/src/interfaces/linux.rs +++ b/netwatch/src/interfaces/linux.rs @@ -28,17 +28,7 @@ pub enum Error { MissingMaskField {}, #[cfg(not(target_os = "android"))] #[error("netlink")] - Netlink { - source: netlink_proto::Error, - }, - #[cfg(not(target_os = "android"))] - #[error("unexpected netlink message")] - UnexpectedNetlinkMessage {}, - #[cfg(not(target_os = "android"))] - #[error("netlink error message: {message:?}")] - NetlinkErrorMessage { - message: netlink_packet_core::ErrorMessage, - }, + Netlink { source: netwatch_netlink::Error }, } pub async fn default_route() -> Option { @@ -143,162 +133,57 @@ mod android { #[cfg(not(target_os = "android"))] mod sane { use n0_error::e; - use n0_future::{Either, StreamExt, TryStream}; - use netlink_packet_core::{NLM_F_DUMP, NLM_F_REQUEST, NetlinkMessage}; - use netlink_packet_route::{ - AddressFamily, RouteNetlinkMessage, - link::{LinkAttribute, LinkMessage}, - route::{RouteAttribute, RouteHeader, RouteMessage, RouteProtocol, RouteScope, RouteType}, - }; - use netlink_sys::protocols::NETLINK_ROUTE; - use tracing::{Instrument, info_span}; + use netwatch_netlink::{AsyncConnection, RouteFamily}; use super::*; - type Handle = netlink_proto::ConnectionHandle; - - macro_rules! try_rtnl { - ($msg: expr, $message_type:path) => {{ - use netlink_packet_core::NetlinkPayload; - use netlink_packet_route::RouteNetlinkMessage; - - let (_header, payload) = $msg.into_parts(); - match payload { - NetlinkPayload::InnerMessage($message_type(msg)) => msg, - NetlinkPayload::Error(err) => { - return Err(e!(Error::NetlinkErrorMessage { message: err })); - } - _ => return Err(e!(Error::UnexpectedNetlinkMessage)), - } - }}; - } - pub async fn default_route() -> Result, Error> { - let (connection, handle, _receiver) = - netlink_proto::new_connection::(NETLINK_ROUTE)?; + let mut conn = AsyncConnection::new().map_err(|err| e!(Error::Netlink, err))?; - let task = tokio::spawn(connection.instrument(info_span!("netlink.conn"))); - - let default = default_route_netlink_family(&handle, AddressFamily::Inet).await?; + let default = default_route_netlink_family(&mut conn, RouteFamily::Ipv4).await?; let default = match default { Some(default) => Some(default), - None => { - default_route_netlink_family(&handle, netlink_packet_route::AddressFamily::Inet6) - .await? - } + None => default_route_netlink_family(&mut conn, RouteFamily::Ipv6).await?, }; - task.abort(); - task.await.ok(); Ok(default.map(|(name, _index)| DefaultRouteDetails { interface_name: name, })) } - fn get_route( - handle: Handle, - message: RouteMessage, - ) -> impl TryStream { - let mut req = NetlinkMessage::from(RouteNetlinkMessage::GetRoute(message)); - req.header.flags = NLM_F_REQUEST | NLM_F_DUMP; - - match handle.request(req, netlink_proto::sys::SocketAddr::new(0, 0)) { - Ok(response) => Either::Left( - response.map(move |msg| Ok(try_rtnl!(msg, RouteNetlinkMessage::NewRoute))), - ), - Err(e) => Either::Right(n0_future::stream::once::>(Err( - e!(Error::Netlink, e), - ))), - } - } - - fn create_route_message(family: netlink_packet_route::AddressFamily) -> RouteMessage { - let mut message = RouteMessage::default(); - message.header.table = RouteHeader::RT_TABLE_MAIN; - message.header.protocol = RouteProtocol::Static; - message.header.scope = RouteScope::Universe; - message.header.kind = RouteType::Unicast; - message.header.address_family = family; - message - } - /// Returns the `(name, index)` of the interface for the default route. async fn default_route_netlink_family( - handle: &Handle, - family: netlink_packet_route::AddressFamily, + conn: &mut AsyncConnection, + family: RouteFamily, ) -> Result, Error> { - let msg = create_route_message(family); - let mut routes = get_route(handle.clone(), msg); - - while let Some(route) = routes.try_next().await? { - let route_attrs = route.attributes; - - if !route_attrs - .iter() - .any(|attr| matches!(attr, RouteAttribute::Gateway(_))) - { + let routes = conn.dump_routes(family).await?; + for route in routes { + if route.gateway.is_none() { // A default route has a gateway. continue; } - - if route.header.destination_prefix_length > 0 { + if route.dst_len > 0 { // A default route has no destination prefix length because it needs to route all // destinations. continue; } - - let index = route_attrs.iter().find_map(|attr| match attr { - RouteAttribute::Oif(index) => Some(*index), - _ => None, - }); - - if let Some(index) = index { - if index == 0 { - continue; - } - let name = iface_by_index(handle, index).await?; - return Ok(Some((name, index))); + let Some(index) = route.oif else { + continue; + }; + if index == 0 { + continue; } + let name = iface_by_index(conn, index).await?; + return Ok(Some((name, index))); } Ok(None) } - fn get_link( - handle: Handle, - message: LinkMessage, - ) -> impl TryStream { - let mut req = NetlinkMessage::from(RouteNetlinkMessage::GetLink(message)); - req.header.flags = NLM_F_REQUEST; - - match handle.request(req, netlink_proto::sys::SocketAddr::new(0, 0)) { - Ok(response) => Either::Left( - response.map(move |msg| Ok(try_rtnl!(msg, RouteNetlinkMessage::NewLink))), - ), - Err(e) => Either::Right(n0_future::stream::once::>(Err( - e!(Error::Netlink, e), - ))), - } - } - - fn create_link_get_message(index: u32) -> LinkMessage { - let mut message = LinkMessage::default(); - message.header.index = index; - message - } - - async fn iface_by_index(handle: &Handle, index: u32) -> Result { - let message = create_link_get_message(index); - let mut links = get_link(handle.clone(), message); - let msg = links - .try_next() + async fn iface_by_index(conn: &mut AsyncConnection, index: u32) -> Result { + let link = conn + .get_link_by_index(index) .await? .ok_or_else(|| e!(Error::NoResponse))?; - - for nla in msg.attributes { - if let LinkAttribute::IfName(name) = nla { - return Ok(name); - } - } - Err(e!(Error::InterfaceNotFound)) + link.name.ok_or_else(|| e!(Error::InterfaceNotFound)) } #[cfg(test)] diff --git a/netwatch/src/netmon/linux.rs b/netwatch/src/netmon/linux.rs index a0d6d4f8..59bf5a88 100644 --- a/netwatch/src/netmon/linux.rs +++ b/netwatch/src/netmon/linux.rs @@ -9,13 +9,10 @@ use libc::{ }; use n0_error::stack_error; use n0_future::{ - Stream, StreamExt, task::AbortOnDropHandle, time::{self, Duration}, }; -use netlink_packet_core::{NetlinkMessage, NetlinkPayload}; -use netlink_packet_route::{RouteNetlinkMessage, address, route}; -use netlink_sys::{AsyncSocket, SocketAddr}; +use netwatch_netlink::{EventSocket, Message, group_flag}; use tokio::sync::mpsc; use tracing::{trace, warn}; @@ -34,145 +31,99 @@ pub enum Error { Io { source: std::io::Error }, } -const fn nl_mgrp(group: u32) -> u32 { - if group > 31 { - panic!("use netlink_sys::Socket::add_membership() for this group"); - } - if group == 0 { 0 } else { 1 << (group - 1) } -} -macro_rules! get_nla { - ($msg:expr, $nla:path) => { - $msg.attributes.iter().find_map(|nla| match nla { - $nla(n) => Some(n), - _ => None, - }) - }; -} - -#[allow(clippy::type_complexity)] -fn setup_netlink() -> std::io::Result<( - AbortOnDropHandle<()>, - impl Stream, SocketAddr)>, -)> { - use netlink_sys::protocols::NETLINK_ROUTE; - - let (mut conn, _handle, messages) = - netlink_proto::new_connection::(NETLINK_ROUTE)?; - - let groups = nl_mgrp(RTNLGRP_IPV4_IFADDR) - | nl_mgrp(RTNLGRP_IPV6_IFADDR) - | nl_mgrp(RTNLGRP_IPV4_ROUTE) - | nl_mgrp(RTNLGRP_IPV6_ROUTE) - | nl_mgrp(RTNLGRP_IPV4_RULE) - | nl_mgrp(RTNLGRP_IPV6_RULE); - - let addr = SocketAddr::new(0, groups); - conn.socket_mut().socket_mut().bind(&addr)?; - - let conn_handle = AbortOnDropHandle::new(tokio::task::spawn(conn)); - - Ok((conn_handle, messages)) +/// Subscribes to the rtnetlink groups netwatch reacts to: address, route +/// and rule changes for both address families. +fn subscribe() -> Result { + let groups = group_flag(RTNLGRP_IPV4_IFADDR) + | group_flag(RTNLGRP_IPV6_IFADDR) + | group_flag(RTNLGRP_IPV4_ROUTE) + | group_flag(RTNLGRP_IPV6_ROUTE) + | group_flag(RTNLGRP_IPV4_RULE) + | group_flag(RTNLGRP_IPV6_RULE); + EventSocket::subscribe(groups) } /// Returns `true` if the connection was lost (should reconnect), /// `false` if the sender is gone (should shut down). -async fn process_messages( - sender: &mpsc::Sender, - messages: &mut (impl Stream, SocketAddr)> + Unpin), -) -> bool { +async fn process_messages(sender: &mpsc::Sender, events: &mut EventSocket) -> bool { let mut addr_cache: HashMap> = HashMap::new(); - while let Some((message, _)) = messages.next().await { - match message.payload { - NetlinkPayload::Error(err) => { - warn!("error reading netlink payload: {:?}", err); + loop { + let message = match events.next().await { + Ok(message) => message, + Err(netwatch_netlink::Error::ErrorMessage { code, .. }) => { + warn!("error reading netlink payload: code {code}"); + continue; } - NetlinkPayload::Done(_) => { - trace!("done received, reconnecting"); + Err(err) => { + trace!("netlink event socket lost ({err:?}), reconnecting"); return true; } - NetlinkPayload::InnerMessage(msg) => match msg { - RouteNetlinkMessage::NewAddress(msg) => { - trace!("NEWADDR: {:?}", msg); - let addrs = addr_cache.entry(msg.header.index).or_default(); - if let Some(addr) = get_nla!(msg, address::AddressAttribute::Address) { - if addrs.contains(addr) { - continue; - } else { - addrs.insert(*addr); - if sender.send(NetworkMessage::Change).await.is_err() { - return false; - } + }; + match message { + Message::NewAddress(msg) => { + trace!("NEWADDR: {:?}", msg); + let addrs = addr_cache.entry(msg.index).or_default(); + if let Some(addr) = msg.address { + if addrs.contains(&addr) { + continue; + } else { + addrs.insert(addr); + if sender.send(NetworkMessage::Change).await.is_err() { + return false; } } } - RouteNetlinkMessage::DelAddress(msg) => { - trace!("DELADDR: {:?}", msg); - let addrs = addr_cache.entry(msg.header.index).or_default(); - if let Some(addr) = get_nla!(msg, address::AddressAttribute::Address) { - addrs.remove(addr); - } - if sender.send(NetworkMessage::Change).await.is_err() { - return false; - } - } - RouteNetlinkMessage::NewRoute(msg) | RouteNetlinkMessage::DelRoute(msg) => { - trace!("ROUTE:: {:?}", msg); - - let table = get_nla!(msg, route::RouteAttribute::Table) - .copied() - .unwrap_or_default(); - if let Some(dst) = get_nla!(msg, route::RouteAttribute::Destination) { - match dst { - route::RouteAddress::Inet(addr) - if (table == 255 || table == 254) - && (addr.is_multicast() - || is_link_local(IpAddr::V4(*addr))) => - { - continue; - } - route::RouteAddress::Inet6(addr) - if (table == 255 || table == 254) - && (addr.is_multicast() - || is_link_local(IpAddr::V6(*addr))) => - { - continue; - } - _ => {} - } - } - if sender.send(NetworkMessage::Change).await.is_err() { - return false; - } + } + Message::DelAddress(msg) => { + trace!("DELADDR: {:?}", msg); + let addrs = addr_cache.entry(msg.index).or_default(); + if let Some(addr) = msg.address { + addrs.remove(&addr); } - RouteNetlinkMessage::NewRule(msg) => { - trace!("NEWRULE: {:?}", msg); - if sender.send(NetworkMessage::Change).await.is_err() { - return false; - } + if sender.send(NetworkMessage::Change).await.is_err() { + return false; } - RouteNetlinkMessage::DelRule(msg) => { - trace!("DELRULE: {:?}", msg); - if sender.send(NetworkMessage::Change).await.is_err() { - return false; - } + } + Message::NewRoute(msg) | Message::DelRoute(msg) => { + trace!("ROUTE:: {:?}", msg); + + let table = msg.table.unwrap_or_default(); + if let Some(dst) = msg.destination + && (table == 255 || table == 254) + && (dst.is_multicast() || is_link_local(dst)) + { + // Ignore multicast and link-local route changes in the + // local and main tables; they are not interesting. + continue; } - RouteNetlinkMessage::NewLink(msg) => { - trace!("NEWLINK: {:?}", msg); + if sender.send(NetworkMessage::Change).await.is_err() { + return false; } - RouteNetlinkMessage::DelLink(msg) => { - trace!("DELLINK: {:?}", msg); + } + Message::NewRule => { + trace!("NEWRULE"); + if sender.send(NetworkMessage::Change).await.is_err() { + return false; } - msg => { - trace!("unhandled: {:?}", msg); + } + Message::DelRule => { + trace!("DELRULE"); + if sender.send(NetworkMessage::Change).await.is_err() { + return false; } - }, - _ => {} + } + Message::NewLink(msg) => { + trace!("NEWLINK: {:?}", msg); + } + Message::DelLink(msg) => { + trace!("DELLINK: {:?}", msg); + } + msg => { + trace!("unhandled: {:?}", msg); + } } } - - // Stream ended — connection lost - true } impl RouteMonitor { @@ -182,11 +133,11 @@ impl RouteMonitor { const MAX_BACKOFF: Duration = Duration::from_secs(30); loop { - match setup_netlink() { - Ok((_conn_handle, mut messages)) => { + match subscribe() { + Ok(mut events) => { backoff = Duration::from_secs(1); - let should_reconnect = process_messages(&sender, &mut messages).await; - // _conn_handle dropped here, aborting the connection task + let should_reconnect = process_messages(&sender, &mut events).await; + // events dropped here, closing the socket if !should_reconnect { break; } From 8863e7774dfdd1478a2a69afc11b79af077d7c46 Mon Sep 17 00:00:00 2001 From: Frando Date: Mon, 17 Aug 2026 17:17:08 +0200 Subject: [PATCH 03/10] refactor: assemble interface state in an enumerate module Pure restructuring ahead of inlining netdev: get_state, home_router and LocalAddresses now live in interfaces::enumerate, built on two platform primitives (interfaces() and default_gateway()) with the netdev-backed implementations isolated in a temporary netdev_shim backend. The local IP probe (UDP connect trick) is inlined since it is plain std. Platform backends replace the shim one by one in the following commits. Co-Authored-By: Claude Fable 5 --- netwatch/src/interfaces.rs | 9 +- netwatch/src/interfaces/bsd.rs | 11 +- .../{netdev_impl.rs => enumerate.rs} | 171 ++++++++---------- .../src/interfaces/enumerate/netdev_shim.rs | 87 +++++++++ netwatch/src/interfaces/linux.rs | 2 +- netwatch/src/interfaces/windows.rs | 2 +- netwatch/src/ip.rs | 5 +- 7 files changed, 175 insertions(+), 112 deletions(-) rename netwatch/src/interfaces/{netdev_impl.rs => enumerate.rs} (61%) create mode 100644 netwatch/src/interfaces/enumerate/netdev_shim.rs diff --git a/netwatch/src/interfaces.rs b/netwatch/src/interfaces.rs index 9073aea5..edc5d9bc 100644 --- a/netwatch/src/interfaces.rs +++ b/netwatch/src/interfaces.rs @@ -3,8 +3,9 @@ //! All public types are defined here once and have the same shape on every //! platform. The platform-specific work of enumerating interfaces, finding the //! default route, and locating the home router lives in the submodules below, -//! each reached through the cfg-selected `platform` alias. Conversion from the -//! `netdev` crate is confined to the `netdev_impl` module. +//! each reached through the cfg-selected `platform` alias. Interface +//! enumeration and state assembly shared by the full platforms lives in the +//! `enumerate` module. use std::{ collections::HashMap, @@ -19,9 +20,9 @@ use crate::ip::{LocalAddresses, is_link_local}; // Each platform module provides the same three entry points, reached through // the `platform` alias: `get_state()`, `default_route()` and `home_router()`. -// The `netdev`-capable modules share enumeration via `netdev_impl`. +// The full platforms share enumeration and state assembly via `enumerate`. #[cfg(netdev)] -mod netdev_impl; +mod enumerate; #[cfg(bsd)] pub(super) mod bsd; diff --git a/netwatch/src/interfaces/bsd.rs b/netwatch/src/interfaces/bsd.rs index c5df5f50..bd084b6f 100644 --- a/netwatch/src/interfaces/bsd.rs +++ b/netwatch/src/interfaces/bsd.rs @@ -16,7 +16,7 @@ use libc::{ use n0_error::{e, ensure, stack_error}; use tracing::warn; -pub(super) use super::netdev_impl::get_state; +pub(super) use super::enumerate::get_state; use super::{DefaultRouteDetails, HomeRouter}; #[cfg(target_os = "freebsd")] @@ -39,7 +39,7 @@ use self::macos::*; pub async fn default_route() -> Option { let idx = default_route_interface_index()?; - let interfaces = netdev::get_interfaces(); + let interfaces = super::enumerate::interfaces(); let iface = interfaces.into_iter().find(|i| i.index == idx)?; Some(DefaultRouteDetails { @@ -49,14 +49,13 @@ pub async fn default_route() -> Option { /// Locates the home router via the routing table. /// -/// `netdev` cannot yet determine the default gateway on BSD platforms (see -/// ), so this parses the -/// routing table directly. The local IP still comes from `netdev`. +/// BSD platforms parse the routing table directly rather than going through +/// the shared `default_gateway()` backend primitive. pub(super) fn home_router() -> Option { let gateway = likely_home_router()?; Some(HomeRouter { gateway, - my_ip: super::netdev_impl::local_ip(), + my_ip: super::enumerate::local_ip(), }) } diff --git a/netwatch/src/interfaces/netdev_impl.rs b/netwatch/src/interfaces/enumerate.rs similarity index 61% rename from netwatch/src/interfaces/netdev_impl.rs rename to netwatch/src/interfaces/enumerate.rs index 749a2ca1..cc7d42d3 100644 --- a/netwatch/src/interfaces/netdev_impl.rs +++ b/netwatch/src/interfaces/enumerate.rs @@ -1,87 +1,43 @@ -//! Conversion from the `netdev` crate into our platform-agnostic types. +//! Interface enumeration and state assembly. //! -//! This module also holds the interface enumeration and home-router lookup -//! shared by all `netdev`-capable platforms (linux, android, bsd, macos, -//! windows). -//! -//! This is the only module that depends on `netdev`. Everything it produces is -//! expressed in terms of the types defined in [`crate::interfaces`]. +//! [`get_state`], [`home_router`] and [`LocalAddresses`] are assembled here +//! from two platform primitives: `interfaces()`, the list of network +//! interfaces in our own [`Interface`] type, and `default_gateway()`, the +//! gateway address of the default route. BSD platforms do not use +//! `default_gateway()`; they parse the routing table in +//! [`crate::interfaces::bsd`] instead. -use std::net::IpAddr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; -use super::{Interface, IpNet, Ipv6AddrFlags, State}; +use super::{Interface, State}; use crate::ip::{LocalAddresses, is_link_local, is_private, is_private_v6}; -const IFF_UP: u32 = 0x1; -const IFF_LOOPBACK: u32 = 0x8; +mod netdev_shim; +use netdev_shim as platform; -/// Converts netdev's IPv6 address flags into our mirrored [`Ipv6AddrFlags`]. +/// The interface flag bit indicating a loopback interface. /// -/// This is a free function rather than a `From` impl on purpose: a public -/// `From` would re-expose the `netdev` type in our public API, -/// which is exactly what the [`Ipv6AddrFlags`] mirror exists to avoid. -fn to_ipv6_addr_flags(flags: netdev::interface::ipv6_addr_flags::Ipv6AddrFlags) -> Ipv6AddrFlags { - Ipv6AddrFlags { - deprecated: flags.deprecated, - temporary: flags.temporary, - tentative: flags.tentative, - duplicated: flags.duplicated, - permanent: flags.permanent, - } -} +/// Matches the POSIX `IFF_LOOPBACK` value. The windows backend synthesizes +/// BSD-style flags but maps loopback to the winsock value `0x4`, so this +/// bit is never set there; loopback addresses are still classified by +/// [`IpAddr::is_loopback`]. +const IFF_LOOPBACK: u32 = 0x8; -/// Converts a [`netdev::Interface`] into our platform-agnostic [`Interface`]. -/// -/// Addresses are sorted (IPv4 first, then IPv6, each by address) so that -/// comparisons between successive snapshots are stable. -fn to_interface(iface: netdev::Interface) -> Interface { - // netdev keeps these three IPv6 arrays parallel, one entry per address. - // The zip below relies on that; assert it so a netdev change that breaks - // the invariant surfaces in tests rather than silently dropping addresses. - debug_assert_eq!(iface.ipv6.len(), iface.ipv6_scope_ids.len()); - debug_assert_eq!(iface.ipv6.len(), iface.ipv6_addr_flags.len()); - - let mut v4: Vec = iface.ipv4.iter().copied().map(IpNet::V4).collect(); - let mut v6: Vec = iface - .ipv6 - .iter() - .copied() - .zip(iface.ipv6_scope_ids.iter().copied()) - .zip(iface.ipv6_addr_flags.iter().copied()) - .map(|((net, scope_id), flags)| IpNet::V6 { - net, - scope_id, - flags: to_ipv6_addr_flags(flags), - }) - .collect(); - - // Sort each family by address so successive snapshots compare equal, then - // concatenate as IPv4-first. - v4.sort_by_key(IpNet::addr); - v6.sort_by_key(IpNet::addr); - let mut addrs = v4; - addrs.append(&mut v6); - - Interface { - name: iface.name, - index: iface.index, - flags: iface.flags, - mac_addr: iface.mac_addr.as_ref().map(|a| a.octets()), - addrs, - } +/// Enumerates the machine's network interfaces. +pub(super) fn interfaces() -> Vec { + platform::interfaces() } /// Enumerates the machine's network interfaces and assembles the [`State`]. pub(super) async fn get_state() -> State { - let raw = netdev::interface::get_interfaces(); - let local_addresses = local_addresses(&raw); + let ifaces = interfaces(); + let local_addresses = local_addresses(&ifaces); let mut interfaces = std::collections::HashMap::new(); let mut have_v6 = false; let mut have_v4 = false; - for raw in raw { - let iface = to_interface(raw); + for iface in ifaces { if iface.is_up() { for pfx in iface.addrs() { let addr = pfx.addr(); @@ -110,25 +66,49 @@ pub(super) async fn get_state() -> State { /// The shared home-router lookup for linux, android and windows. /// -/// BSD platforms do not use this as `netdev` cannot yet determine their default -/// gateway, so they provide their own implementation. +/// BSD platforms do not use this; they parse the routing table directly in +/// [`crate::interfaces::bsd`]. #[cfg(any(target_os = "linux", target_os = "android", target_os = "windows"))] pub(super) fn home_router() -> Option { - let gateway = netdev::get_default_gateway().ok()?; - let gateway = gateway - .ipv4 - .iter() - .copied() - .map(IpAddr::V4) - .chain(gateway.ipv6.iter().copied().map(IpAddr::V6)) - .next()?; - + let gateway = platform::default_gateway()?; Some(super::HomeRouter { gateway, my_ip: local_ip(), }) } +/// The local IP address selected for outbound traffic. +/// +/// Opens a UDP socket and lets the operating system choose the source +/// address it would use to reach a non-routable destination; no packets +/// are sent. +pub(super) fn local_ip() -> Option { + // Binding the IPv4 socket can succeed while a later step fails in + // IPv6-only environments, so fall back to IPv6 whenever any IPv4 step + // fails. + local_ip_family( + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 254, 254, 254)), 1), + ) + .or_else(|| { + local_ip_family( + SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), + SocketAddr::new( + IpAddr::V6(Ipv6Addr::new( + 0xfdff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, + )), + 1, + ), + ) + }) +} + +fn local_ip_family(bind: SocketAddr, probe: SocketAddr) -> Option { + let socket = UdpSocket::bind(bind).ok()?; + socket.connect(probe).ok()?; + Some(socket.local_addr().ok()?.ip()) +} + /// Reports whether `ip` is a usable IPv4 address which should have Internet connectivity. /// /// Globally routable and private IPv4 addresses are always usable, and link-local @@ -169,25 +149,16 @@ fn is_usable_v6(ip: &IpAddr) -> bool { } } -/// The local IP address of this machine, as reported by `netdev`. -pub(super) fn local_ip() -> Option { - netdev::net::ip::get_local_ipaddr() -} - -const fn is_up(interface: &netdev::Interface) -> bool { - interface.flags & IFF_UP != 0 -} - -const fn is_loopback(interface: &netdev::Interface) -> bool { +const fn is_loopback(interface: &Interface) -> bool { interface.flags & IFF_LOOPBACK != 0 } -/// Builds the machine's [`LocalAddresses`] from a raw `netdev` interface list. +/// Builds the machine's [`LocalAddresses`] from an interface list. /// /// If there are no regular addresses it falls back to IPv4 link-local or IPv6 /// unique-local addresses, because we know of environments where these are used /// with NAT to provide connectivity. -fn local_addresses(ifaces: &[netdev::Interface]) -> LocalAddresses { +fn local_addresses(ifaces: &[Interface]) -> LocalAddresses { let mut loopback = Vec::new(); let mut regular4 = Vec::new(); let mut regular6 = Vec::new(); @@ -195,18 +166,13 @@ fn local_addresses(ifaces: &[netdev::Interface]) -> LocalAddresses { let mut ula6 = Vec::new(); for iface in ifaces { - if !is_up(iface) { + if !iface.is_up() { // Skip down interfaces continue; } let ifc_is_loopback = is_loopback(iface); - let addrs = iface - .ipv4 - .iter() - .map(|a| IpAddr::V4(a.addr())) - .chain(iface.ipv6.iter().map(|a| IpAddr::V6(a.addr()))); - for ip in addrs { + for ip in iface.addrs().map(|pfx| pfx.addr()) { let ip = ip.to_canonical(); if ip.is_loopback() || ifc_is_loopback { @@ -258,7 +224,7 @@ impl LocalAddresses { /// IPv6 unique-local addresses, because we know of environments where these /// are used with NAT to provide connectivity. pub fn new() -> Self { - local_addresses(&netdev::interface::get_interfaces()) + local_addresses(&platform::interfaces()) } } @@ -290,4 +256,13 @@ mod tests { let random_2603 = Ipv6Addr::new(0x2603, 0x3ff, 0xf1, 0xc3aa, 0x1, 0x2, 0x3, 0x1); assert!(is_usable_v6(&random_2603.into())); } + + #[test] + fn test_local_ip() { + // Either family may be unavailable in a test environment; only + // check that a returned address is not unspecified. + if let Some(ip) = local_ip() { + assert!(!ip.is_unspecified()); + } + } } diff --git a/netwatch/src/interfaces/enumerate/netdev_shim.rs b/netwatch/src/interfaces/enumerate/netdev_shim.rs new file mode 100644 index 00000000..7ccd47de --- /dev/null +++ b/netwatch/src/interfaces/enumerate/netdev_shim.rs @@ -0,0 +1,87 @@ +//! Temporary enumeration backend that converts from the `netdev` crate. +//! +//! Platforms move to inlined backends one by one; this shim disappears +//! when the last one (windows) lands. Nothing it produces exposes a +//! `netdev` type, which is enforced by `cargo check-external-types`. + +#[cfg(any(target_os = "linux", target_os = "android", target_os = "windows"))] +use std::net::IpAddr; + +use super::super::{Interface, IpNet, Ipv6AddrFlags}; + +/// Converts netdev's IPv6 address flags into our mirrored [`Ipv6AddrFlags`]. +/// +/// This is a free function rather than a `From` impl on purpose: a public +/// `From` would re-expose the `netdev` type in our public API, +/// which is exactly what the [`Ipv6AddrFlags`] mirror exists to avoid. +fn to_ipv6_addr_flags(flags: netdev::interface::ipv6_addr_flags::Ipv6AddrFlags) -> Ipv6AddrFlags { + Ipv6AddrFlags { + deprecated: flags.deprecated, + temporary: flags.temporary, + tentative: flags.tentative, + duplicated: flags.duplicated, + permanent: flags.permanent, + } +} + +/// Converts a [`netdev::Interface`] into our platform-agnostic [`Interface`]. +/// +/// Addresses are sorted (IPv4 first, then IPv6, each by address) so that +/// comparisons between successive snapshots are stable. +fn to_interface(iface: netdev::Interface) -> Interface { + // netdev keeps these three IPv6 arrays parallel, one entry per address. + // The zip below relies on that; assert it so a netdev change that breaks + // the invariant surfaces in tests rather than silently dropping addresses. + debug_assert_eq!(iface.ipv6.len(), iface.ipv6_scope_ids.len()); + debug_assert_eq!(iface.ipv6.len(), iface.ipv6_addr_flags.len()); + + let mut v4: Vec = iface.ipv4.iter().copied().map(IpNet::V4).collect(); + let mut v6: Vec = iface + .ipv6 + .iter() + .copied() + .zip(iface.ipv6_scope_ids.iter().copied()) + .zip(iface.ipv6_addr_flags.iter().copied()) + .map(|((net, scope_id), flags)| IpNet::V6 { + net, + scope_id, + flags: to_ipv6_addr_flags(flags), + }) + .collect(); + + // Sort each family by address so successive snapshots compare equal, then + // concatenate as IPv4-first. + v4.sort_by_key(IpNet::addr); + v6.sort_by_key(IpNet::addr); + let mut addrs = v4; + addrs.append(&mut v6); + + Interface { + name: iface.name, + index: iface.index, + flags: iface.flags, + mac_addr: iface.mac_addr.as_ref().map(|a| a.octets()), + addrs, + } +} + +/// Enumerates the machine's network interfaces. +pub(super) fn interfaces() -> Vec { + netdev::interface::get_interfaces() + .into_iter() + .map(to_interface) + .collect() +} + +/// The gateway address of the default route, as reported by `netdev`. +#[cfg(any(target_os = "linux", target_os = "android", target_os = "windows"))] +pub(super) fn default_gateway() -> Option { + let gateway = netdev::get_default_gateway().ok()?; + gateway + .ipv4 + .iter() + .copied() + .map(IpAddr::V4) + .chain(gateway.ipv6.iter().copied().map(IpAddr::V6)) + .next() +} diff --git a/netwatch/src/interfaces/linux.rs b/netwatch/src/interfaces/linux.rs index 641f7b69..3fcaf7f7 100644 --- a/netwatch/src/interfaces/linux.rs +++ b/netwatch/src/interfaces/linux.rs @@ -7,7 +7,7 @@ use tokio::{ }; use super::DefaultRouteDetails; -pub(super) use super::netdev_impl::{get_state, home_router}; +pub(super) use super::enumerate::{get_state, home_router}; #[stack_error(derive, add_meta, from_sources, std_sources)] #[non_exhaustive] diff --git a/netwatch/src/interfaces/windows.rs b/netwatch/src/interfaces/windows.rs index ed89c58d..022f9320 100644 --- a/netwatch/src/interfaces/windows.rs +++ b/netwatch/src/interfaces/windows.rs @@ -6,7 +6,7 @@ use tracing::warn; use wmi::{FilterValue, WMIConnection}; use super::DefaultRouteDetails; -pub(super) use super::netdev_impl::{get_state, home_router}; +pub(super) use super::enumerate::{get_state, home_router}; /// API Docs: #[derive(Deserialize, Debug)] diff --git a/netwatch/src/ip.rs b/netwatch/src/ip.rs index 234ccf32..7d7eda3a 100644 --- a/netwatch/src/ip.rs +++ b/netwatch/src/ip.rs @@ -4,8 +4,9 @@ use std::net::{IpAddr, Ipv6Addr}; /// List of machine's IP addresses. /// -/// The netdev-based constructors live in [`crate::interfaces`]'s `netdev_impl` -/// module; on platforms without `netdev` this is only ever the empty default. +/// The constructors live in [`crate::interfaces`]'s `enumerate` module; on +/// platforms without interface enumeration (esp-idf, browsers) this is only +/// ever the empty default. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct LocalAddresses { /// Loopback addresses. From ee836a5424cff6ff5fba56dcea13a6703dfa3716 Mon Sep 17 00:00:00 2001 From: Frando Date: Mon, 17 Aug 2026 20:39:29 +0200 Subject: [PATCH 04/10] feat: inline linux and android interface enumeration Linux and android now enumerate interfaces through netwatch-netlink link and address dumps, falling back to a getifaddrs walk when netlink is unavailable (Android 11+ SELinux denies link dumps to apps; there getifaddrs stays loadable on old releases by resolving the symbol at runtime, like netdev did). The home router gateway comes from a netlink route dump with a procfs fallback on linux. This drops netdev, and with it netlink-packet-route, from all linux and android builds. Verified field-for-field against netdev's output on a live system: identical interfaces (name, index, flags, mac, addresses, prefixes, scope ids, IPv6 address flags) and identical gateway. Co-Authored-By: Claude Fable 5 --- netwatch/Cargo.toml | 7 +- netwatch/src/interfaces/enumerate.rs | 169 +++++++- netwatch/src/interfaces/enumerate/ifaddrs.rs | 413 +++++++++++++++++++ netwatch/src/interfaces/enumerate/netlink.rs | 152 +++++++ netwatch/src/interfaces/enumerate/procfs.rs | 118 ++++++ 5 files changed, 852 insertions(+), 7 deletions(-) create mode 100644 netwatch/src/interfaces/enumerate/ifaddrs.rs create mode 100644 netwatch/src/interfaces/enumerate/netlink.rs create mode 100644 netwatch/src/interfaces/enumerate/procfs.rs diff --git a/netwatch/Cargo.toml b/netwatch/Cargo.toml index 3a25d513..62069d03 100644 --- a/netwatch/Cargo.toml +++ b/netwatch/Cargo.toml @@ -41,14 +41,17 @@ libc = "0.2.139" socket2 = { version = "0.6", features = ["all"] } tokio = { version = "1", features = ["rt", "net"] } -# netdev is available on all non-embedded, non-wasm platforms [target.'cfg(not(any(target_os = "espidf", all(target_family = "wasm", target_os = "unknown"))))'.dependencies] -netdev = "0.45.0" tokio = { version = "1", features = [ "fs", "io-std", ] } +# netdev remains on the platforms whose enumeration backend is not +# inlined yet; linux and android use netwatch-netlink and getifaddrs. +[target.'cfg(any(target_os = "windows", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "macos", target_os = "ios"))'.dependencies] +netdev = "0.45.0" + [target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] # msrv fix objc2-core-foundation = "0.3.2" diff --git a/netwatch/src/interfaces/enumerate.rs b/netwatch/src/interfaces/enumerate.rs index cc7d42d3..03b74355 100644 --- a/netwatch/src/interfaces/enumerate.rs +++ b/netwatch/src/interfaces/enumerate.rs @@ -9,11 +9,19 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; -use super::{Interface, State}; +use ipnet::{Ipv4Net, Ipv6Net}; + +use super::{Interface, IpNet, Ipv6AddrFlags, State}; use crate::ip::{LocalAddresses, is_link_local, is_private, is_private_v6}; +#[cfg(any(target_os = "linux", target_os = "android"))] +mod ifaddrs; +#[cfg(any(target_os = "windows", bsd))] mod netdev_shim; -use netdev_shim as platform; +#[cfg(any(target_os = "linux", target_os = "android"))] +mod netlink; +#[cfg(target_os = "linux")] +mod procfs; /// The interface flag bit indicating a loopback interface. /// @@ -24,8 +32,159 @@ use netdev_shim as platform; const IFF_LOOPBACK: u32 = 0x8; /// Enumerates the machine's network interfaces. +/// +/// Prefers netlink dumps and falls back to getifaddrs when they fail, +/// which notably happens on Android 11+ where SELinux denies netlink link +/// dumps to apps. +#[cfg(any(target_os = "linux", target_os = "android"))] +pub(super) fn interfaces() -> Vec { + match netlink::interfaces() { + Ok(ifaces) => ifaces, + Err(err) => { + tracing::debug!("netlink enumeration failed ({err:?}), falling back to getifaddrs"); + ifaddrs::interfaces() + } + } +} + +/// Enumerates the machine's network interfaces. +#[cfg(any(target_os = "windows", bsd))] pub(super) fn interfaces() -> Vec { - platform::interfaces() + netdev_shim::interfaces() +} + +/// The gateway address of the default route. +/// +/// Follows netdev's algorithm: find the interface owning the local IP the +/// OS routes outbound traffic through, then return that interface's +/// default-route gateway from a netlink route dump, with a procfs fallback +/// on linux when the dump fails. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn default_gateway() -> Option { + let local_ip = local_ip()?; + let ifaces = interfaces(); + let iface = ifaces + .iter() + .find(|iface| iface.addrs.iter().any(|net| net.addr() == local_ip))?; + match netlink::default_gateways_by_interface() { + Ok(mut gateways) => { + let (v4, v6) = gateways.remove(&iface.index)?; + v4.first() + .copied() + .map(IpAddr::V4) + .or_else(|| v6.first().copied().map(IpAddr::V6)) + } + #[cfg(target_os = "linux")] + Err(err) => { + tracing::debug!("netlink route dump failed ({err:?}), trying procfs"); + let (v4, v6) = procfs::gateways_by_interface_name().remove(iface.name())?; + v4.map(IpAddr::V4) + .or_else(|| v6.first().copied().map(IpAddr::V6)) + } + // Android has no readable /proc/net/route; without netlink there + // is no gateway source. + #[cfg(target_os = "android")] + Err(_) => None, + } +} + +/// The gateway address of the default route. +#[cfg(target_os = "windows")] +fn default_gateway() -> Option { + netdev_shim::default_gateway() +} + +/// Accumulates one [`Interface`] during enumeration. +/// +/// The push methods deduplicate addresses on (address, prefix) pairs the +/// way netdev did, and `finish` produces the stable address order the +/// state comparison relies on. +#[cfg(any(target_os = "linux", target_os = "android"))] +#[derive(Debug)] +struct IfaceBuilder { + name: String, + index: u32, + flags: u32, + mac: Option<[u8; 6]>, + v4: Vec, + v6: Vec<(Ipv6Net, u32, Ipv6AddrFlags)>, +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +impl IfaceBuilder { + fn new(name: String, index: u32, flags: u32) -> Self { + Self { + name, + index, + flags, + mac: None, + v4: Vec::new(), + v6: Vec::new(), + } + } + + fn push_v4(&mut self, net: Ipv4Net) { + let duplicate = self + .v4 + .iter() + .any(|have| have.addr() == net.addr() && have.prefix_len() == net.prefix_len()); + if !duplicate { + self.v4.push(net); + } + } + + fn push_v6(&mut self, net: Ipv6Net, scope_id: u32, flags: Ipv6AddrFlags) { + let duplicate = self + .v6 + .iter() + .any(|(have, _, _)| have.addr() == net.addr() && have.prefix_len() == net.prefix_len()); + if !duplicate { + self.v6.push((net, scope_id, flags)); + } + } + + /// Builds the [`Interface`], sorting each address family by address + /// (IPv4 first) so successive snapshots compare equal. + fn finish(self) -> Interface { + let mut v4: Vec = self.v4.into_iter().map(IpNet::V4).collect(); + let mut v6: Vec = self + .v6 + .into_iter() + .map(|(net, scope_id, flags)| IpNet::V6 { + net, + scope_id, + flags, + }) + .collect(); + v4.sort_by_key(IpNet::addr); + v6.sort_by_key(IpNet::addr); + let mut addrs = v4; + addrs.append(&mut v6); + + Interface { + name: self.name, + index: self.index, + flags: self.flags, + mac_addr: self.mac, + addrs, + } + } +} + +/// Returns the scope ID for an IPv6 interface address. +/// +/// Prefers the scope reported by the OS and falls back to the interface +/// index for link-local addresses, which is netdev behavior the rest of +/// the code relies on. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn resolve_v6_scope_id(addr: &Ipv6Addr, raw_scope_id: u32, if_index: u32) -> u32 { + if raw_scope_id != 0 { + raw_scope_id + } else if crate::ip::is_unicast_link_local(*addr) { + if_index + } else { + 0 + } } /// Enumerates the machine's network interfaces and assembles the [`State`]. @@ -70,7 +229,7 @@ pub(super) async fn get_state() -> State { /// [`crate::interfaces::bsd`]. #[cfg(any(target_os = "linux", target_os = "android", target_os = "windows"))] pub(super) fn home_router() -> Option { - let gateway = platform::default_gateway()?; + let gateway = default_gateway()?; Some(super::HomeRouter { gateway, my_ip: local_ip(), @@ -224,7 +383,7 @@ impl LocalAddresses { /// IPv6 unique-local addresses, because we know of environments where these /// are used with NAT to provide connectivity. pub fn new() -> Self { - local_addresses(&platform::interfaces()) + local_addresses(&interfaces()) } } diff --git a/netwatch/src/interfaces/enumerate/ifaddrs.rs b/netwatch/src/interfaces/enumerate/ifaddrs.rs new file mode 100644 index 00000000..3876f44e --- /dev/null +++ b/netwatch/src/interfaces/enumerate/ifaddrs.rs @@ -0,0 +1,413 @@ +//! getifaddrs-based interface enumeration. +//! +//! The primary backend on BSD and Apple platforms, and the fallback on +//! linux and android when netlink is unavailable (Android 11+ denies +//! netlink link dumps to apps via SELinux). +//! +//! Every getifaddrs entry carries one address; entries are merged per +//! interface name, with flags taken from the first entry of a name the +//! way netdev did it. + +use std::{ + ffi::CStr, + net::{Ipv4Addr, Ipv6Addr}, +}; + +use ipnet::{Ipv4Net, Ipv6Net}; + +use super::{IfaceBuilder, Interface, Ipv6AddrFlags, resolve_v6_scope_id}; + +/// The address family link-layer (MAC) addresses arrive with. +#[cfg(any(target_os = "linux", target_os = "android"))] +const MAC_FAMILY: libc::c_int = libc::AF_PACKET; +#[cfg(bsd)] +const MAC_FAMILY: libc::c_int = libc::AF_LINK; + +/// Enumerates the machine's network interfaces via `getifaddrs(3)`. +/// +/// Returns an empty list when the call fails; there is no error channel, +/// matching the behavior callers have relied on so far. +pub(super) fn interfaces() -> Vec { + #[cfg(target_os = "android")] + let Some((getifaddrs_fn, freeifaddrs_fn)) = compat::symbols() else { + return Vec::new(); + }; + #[cfg(not(target_os = "android"))] + let (getifaddrs_fn, freeifaddrs_fn) = ( + libc::getifaddrs as unsafe extern "C" fn(*mut *mut libc::ifaddrs) -> libc::c_int, + libc::freeifaddrs as unsafe extern "C" fn(*mut libc::ifaddrs), + ); + + let mut list: *mut libc::ifaddrs = std::ptr::null_mut(); + // SAFETY: `list` is a valid out-pointer for getifaddrs. + if unsafe { getifaddrs_fn(&mut list) } != 0 { + return Vec::new(); + } + + let mut builders: Vec = Vec::new(); + let mut entry = list; + while !entry.is_null() { + // SAFETY: `entry` points at a live node of the getifaddrs list. + let ifa = unsafe { &*entry }; + entry = ifa.ifa_next; + + if ifa.ifa_name.is_null() { + continue; + } + // SAFETY: `ifa_name` is a NUL-terminated string owned by the list. + let name = unsafe { CStr::from_ptr(ifa.ifa_name) } + .to_string_lossy() + .into_owned(); + + let position = match builders.iter().position(|builder| builder.name == name) { + Some(position) => position, + None => { + // SAFETY: `ifa_name` is valid for the duration of the call. + let index = unsafe { libc::if_nametoindex(ifa.ifa_name) }; + builders.push(IfaceBuilder::new(name, index, ifa.ifa_flags as u32)); + builders.len() - 1 + } + }; + let builder = &mut builders[position]; + + // SAFETY: the entry's sockaddr pointers are valid or null. + let Some((family, sa)) = (unsafe { sockaddr_slice(ifa.ifa_addr) }) else { + continue; + }; + // SAFETY: as above. + let netmask = unsafe { sockaddr_slice(ifa.ifa_netmask) }; + + if family == MAC_FAMILY { + if let Some(mac) = parse_mac(sa) { + builder.mac = Some(mac); + } + } else if family == libc::AF_INET { + let Some(ip) = parse_v4_addr(sa) else { + continue; + }; + // A non-contiguous netmask fails here and drops the address. + let Ok(net) = Ipv4Net::with_netmask(ip, parse_v4_mask(netmask)) else { + continue; + }; + builder.push_v4(net); + } else if family == libc::AF_INET6 { + let Some((ip, raw_scope_id)) = parse_v6_addr(sa) else { + continue; + }; + let Ok(net) = Ipv6Net::with_netmask(ip, parse_v6_mask(netmask)) else { + continue; + }; + let scope_id = resolve_v6_scope_id(&ip, raw_scope_id, builder.index); + let flags = ipv6_addr_flags(&builder.name, &ip); + builder.push_v6(net, scope_id, flags); + } + } + + // SAFETY: `list` came from getifaddrs and is freed exactly once. + unsafe { freeifaddrs_fn(list) }; + + builders.into_iter().map(IfaceBuilder::finish).collect() +} + +/// Reads the address family and the valid bytes of a sockaddr. +/// +/// On BSD-derived systems the length comes from `sa_len` (clamped to the +/// size of `sockaddr_storage`, with family-based defaults when zero); on +/// linux and android it is implied by the family. +/// +/// # Safety +/// +/// `sa` must be null or point to a sockaddr whose reported length is +/// backed by its allocation, as getifaddrs guarantees. +unsafe fn sockaddr_slice<'a>(sa: *const libc::sockaddr) -> Option<(libc::c_int, &'a [u8])> { + if sa.is_null() { + return None; + } + // SAFETY: `sa` points at a sockaddr per the caller contract. + let family = unsafe { (*sa).sa_family } as libc::c_int; + + #[cfg(bsd)] + let len = { + // SAFETY: as above. + let sa_len = unsafe { (*sa).sa_len } as usize; + if sa_len == 0 { + match family { + libc::AF_INET => std::mem::size_of::(), + libc::AF_INET6 => std::mem::size_of::(), + libc::AF_LINK => std::mem::size_of::(), + _ => return None, + } + } else { + sa_len.min(std::mem::size_of::()) + } + }; + #[cfg(any(target_os = "linux", target_os = "android"))] + let len = match family { + libc::AF_INET => std::mem::size_of::(), + libc::AF_INET6 => std::mem::size_of::(), + libc::AF_PACKET => std::mem::size_of::(), + _ => return None, + }; + + // SAFETY: `len` bytes are backed per the caller contract. + Some((family, unsafe { + std::slice::from_raw_parts(sa.cast::(), len) + })) +} + +/// Parses the address of a full-length `sockaddr_in`. +fn parse_v4_addr(sa: &[u8]) -> Option { + // sin_addr sits at offset 4 on every supported platform. + if sa.len() < std::mem::size_of::() { + return None; + } + Some(Ipv4Addr::new(sa[4], sa[5], sa[6], sa[7])) +} + +/// Parses the address and scope of a full-length `sockaddr_in6`. +fn parse_v6_addr(sa: &[u8]) -> Option<(Ipv6Addr, u32)> { + // sin6_addr sits at offset 8 and sin6_scope_id at 24 on every + // supported platform. + if sa.len() < std::mem::size_of::() { + return None; + } + let octets: [u8; 16] = sa[8..24].try_into().expect("length checked"); + let scope_id = u32::from_ne_bytes(sa[24..28].try_into().expect("length checked")); + Some((Ipv6Addr::from(octets), scope_id)) +} + +/// Extracts an IPv4 netmask, tolerating the truncated sockaddrs BSD +/// kernels produce (trailing zero bytes are trimmed from `sa_len`). +fn parse_v4_mask(netmask: Option<(libc::c_int, &[u8])>) -> Ipv4Addr { + let Some((family, sa)) = netmask else { + return Ipv4Addr::UNSPECIFIED; + }; + if family != libc::AF_INET { + return Ipv4Addr::UNSPECIFIED; + } + let mut octets = [0u8; 4]; + let available = sa.len().saturating_sub(4).min(4); + octets[..available].copy_from_slice(&sa[4..4 + available]); + Ipv4Addr::from(octets) +} + +/// Extracts an IPv6 netmask; see [`parse_v4_mask`] for the truncation +/// handling. +fn parse_v6_mask(netmask: Option<(libc::c_int, &[u8])>) -> Ipv6Addr { + let Some((family, sa)) = netmask else { + return Ipv6Addr::UNSPECIFIED; + }; + if family != libc::AF_INET6 { + return Ipv6Addr::UNSPECIFIED; + } + let mut octets = [0u8; 16]; + let available = sa.len().saturating_sub(8).min(16); + octets[..available].copy_from_slice(&sa[8..8 + available]); + Ipv6Addr::from(octets) +} + +/// Extracts the MAC address from a `sockaddr_ll`. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn parse_mac(sa: &[u8]) -> Option<[u8; 6]> { + // sll_halen sits at offset 11, sll_addr at 12. + let halen = *sa.get(11)? as usize; + if halen < 6 { + return None; + } + sa.get(12..18)?.try_into().ok() +} + +/// Extracts the MAC address from a `sockaddr_dl`. +/// +/// Wire layout: len, family, index (2), type, name length, address +/// length, selector length, then name and address bytes. +#[cfg(bsd)] +fn parse_mac(sa: &[u8]) -> Option<[u8; 6]> { + if sa.len() < 8 { + return None; + } + let name_len = sa[5] as usize; + let addr_len = sa[6] as usize; + if addr_len < 6 { + return None; + } + let start = 8 + name_len; + sa.get(start..start + 6)?.try_into().ok() +} + +/// Queries the per-address IPv6 flags via the `SIOCGIFAFLAG_IN6` ioctl. +/// +/// Any failure yields all-false flags; `permanent` is never reported on +/// these platforms. +#[cfg(bsd)] +fn ipv6_addr_flags(name: &str, addr: &Ipv6Addr) -> Ipv6AddrFlags { + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + + // From in6_var.h (xnu and FreeBSD agree); the libc crate exposes + // neither the ioctl number nor the flag bits. The request encodes a + // 288-byte `struct in6_ifreq`. The same number is used on OpenBSD and + // NetBSD even though their struct differs; there the ioctl fails and + // the flags stay false, which is the behavior netdev shipped. + const SIOCGIFAFLAG_IN6: libc::c_ulong = 0xC120_6949; + const IN6_IFF_TENTATIVE: i32 = 0x02; + const IN6_IFF_DUPLICATED: i32 = 0x04; + const IN6_IFF_DEPRECATED: i32 = 0x10; + const IN6_IFF_TEMPORARY: i32 = 0x80; + + /// Layout-compatible with the kernel's `struct in6_ifreq`: the + /// interface name followed by a union accessed only as the request + /// address (in) and the flag word (out). `data` is sized so the + /// struct matches the 288 bytes the ioctl copies. + #[repr(C, align(8))] + struct In6Ifreq { + name: [u8; libc::IFNAMSIZ], + data: [u8; 288 - libc::IFNAMSIZ], + } + + let flags = Ipv6AddrFlags::default(); + + let fd = unsafe { libc::socket(libc::AF_INET6, libc::SOCK_DGRAM, 0) }; + if fd < 0 { + return flags; + } + // SAFETY: `fd` is a freshly created socket owned by no one else. + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + + let mut req = In6Ifreq { + name: [0; libc::IFNAMSIZ], + data: [0; 288 - libc::IFNAMSIZ], + }; + let name = name.as_bytes(); + let name_len = name.len().min(libc::IFNAMSIZ - 1); + req.name[..name_len].copy_from_slice(&name[..name_len]); + + // SAFETY: sockaddr_in6 is valid when zeroed. + let mut sin6: libc::sockaddr_in6 = unsafe { std::mem::zeroed() }; + sin6.sin6_len = std::mem::size_of::() as u8; + sin6.sin6_family = libc::AF_INET6 as libc::sa_family_t; + sin6.sin6_addr.s6_addr = addr.octets(); + // SAFETY: `data` is larger than sockaddr_in6. + unsafe { std::ptr::write_unaligned(req.data.as_mut_ptr().cast::(), sin6) }; + + // SAFETY: `req` matches the size encoded in the ioctl request. + let res = unsafe { libc::ioctl(fd.as_raw_fd(), SIOCGIFAFLAG_IN6, &mut req) }; + if res != 0 { + return flags; + } + // SAFETY: `data` is larger than the flag word. + let raw = unsafe { std::ptr::read_unaligned(req.data.as_ptr().cast::()) }; + + Ipv6AddrFlags { + deprecated: raw & IN6_IFF_DEPRECATED != 0, + temporary: raw & IN6_IFF_TEMPORARY != 0, + tentative: raw & IN6_IFF_TENTATIVE != 0, + duplicated: raw & IN6_IFF_DUPLICATED != 0, + permanent: false, + } +} + +/// On linux and android the per-address IPv6 flags come from netlink; +/// this getifaddrs fallback reports none, like netdev's fallback did. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn ipv6_addr_flags(_name: &str, _addr: &Ipv6Addr) -> Ipv6AddrFlags { + Ipv6AddrFlags::default() +} + +/// `getifaddrs` resolved at runtime. +/// +/// Bionic provides getifaddrs only since API 24; resolving the symbols at +/// runtime keeps binaries loadable on older Android versions, at the +/// price of enumerating nothing there. This matches netdev, which +/// dlopened libc for the same reason. +#[cfg(target_os = "android")] +mod compat { + use std::sync::OnceLock; + + pub(super) type GetIfAddrsFn = unsafe extern "C" fn(*mut *mut libc::ifaddrs) -> libc::c_int; + pub(super) type FreeIfAddrsFn = unsafe extern "C" fn(*mut libc::ifaddrs); + + pub(super) fn symbols() -> Option<(GetIfAddrsFn, FreeIfAddrsFn)> { + static SYMBOLS: OnceLock> = OnceLock::new(); + let (getifaddrs, freeifaddrs) = (*SYMBOLS.get_or_init(|| { + // SAFETY: dlsym with a valid NUL-terminated symbol name. + let getifaddrs = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"getifaddrs".as_ptr()) }; + // SAFETY: as above. + let freeifaddrs = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"freeifaddrs".as_ptr()) }; + if getifaddrs.is_null() || freeifaddrs.is_null() { + None + } else { + Some((getifaddrs as usize, freeifaddrs as usize)) + } + }))?; + // SAFETY: the addresses come from dlsym for symbols with exactly + // these C signatures. + unsafe { + Some(( + std::mem::transmute::(getifaddrs), + std::mem::transmute::(freeifaddrs), + )) + } + } +} + +#[cfg(test)] +mod tests { + use std::net::IpAddr; + + use super::*; + + #[test] + fn test_interfaces_contains_loopback() { + let ifaces = interfaces(); + let lo = ifaces + .iter() + .find(|iface| iface.addrs.iter().any(|net| net.addr().is_loopback())) + .expect("no loopback interface"); + assert!(lo.index >= 1); + assert!(lo.is_up()); + assert!( + lo.addrs + .iter() + .any(|net| net.addr() == IpAddr::V4(Ipv4Addr::LOCALHOST)) + ); + } + + #[test] + fn test_parse_v4_mask() { + // A full-length AF_INET sockaddr with mask 255.255.255.0. + let mut sa = [0u8; 16]; + sa[4..8].copy_from_slice(&[255, 255, 255, 0]); + assert_eq!( + parse_v4_mask(Some((libc::AF_INET, &sa))), + Ipv4Addr::new(255, 255, 255, 0) + ); + // A truncated sockaddr (BSD style): only one mask byte present. + assert_eq!( + parse_v4_mask(Some((libc::AF_INET, &sa[..5]))), + Ipv4Addr::new(255, 0, 0, 0) + ); + // Missing or wrong-family netmask means prefix zero. + assert_eq!(parse_v4_mask(None), Ipv4Addr::UNSPECIFIED); + assert_eq!( + parse_v4_mask(Some((libc::AF_INET6, &sa))), + Ipv4Addr::UNSPECIFIED + ); + } + + #[test] + fn test_parse_v6_addr_and_mask() { + let ip = Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1); + let mut sa = [0u8; 28]; + sa[8..24].copy_from_slice(&ip.octets()); + sa[24..28].copy_from_slice(&3u32.to_ne_bytes()); + assert_eq!(parse_v6_addr(&sa), Some((ip, 3))); + assert_eq!(parse_v6_addr(&sa[..20]), None); + + let mut mask = [0u8; 28]; + mask[8..16].copy_from_slice(&[0xff; 8]); + assert_eq!( + parse_v6_mask(Some((libc::AF_INET6, &mask))), + Ipv6Addr::new(0xffff, 0xffff, 0xffff, 0xffff, 0, 0, 0, 0) + ); + } +} diff --git a/netwatch/src/interfaces/enumerate/netlink.rs b/netwatch/src/interfaces/enumerate/netlink.rs new file mode 100644 index 00000000..0d5e57e4 --- /dev/null +++ b/netwatch/src/interfaces/enumerate/netlink.rs @@ -0,0 +1,152 @@ +//! Netlink-based enumeration backend for linux and android. +//! +//! Interfaces come from an `RTM_GETLINK` plus an `RTM_GETADDR` dump, the +//! default gateways from an `RTM_GETROUTE` dump. Mirrors what netdev did +//! on these platforms, including the android leniency: there the address +//! dump is allowed to fail (yielding interfaces without addresses) while +//! a failed link dump makes the caller fall back to getifaddrs. + +use std::{ + collections::HashMap, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, +}; + +use ipnet::{Ipv4Net, Ipv6Net}; +use netwatch_netlink::{Connection, RouteFamily}; + +use super::{IfaceBuilder, Interface, Ipv6AddrFlags, resolve_v6_scope_id}; + +/// Enumerates interfaces via netlink dumps. +pub(super) fn interfaces() -> Result, netwatch_netlink::Error> { + let mut conn = Connection::new()?; + let links = conn.dump_links()?; + + // Android 11+ SELinux denies link dumps but allows address dumps; the + // reverse does not happen. netdev nevertheless tolerated a failed + // address dump on android only, so keep that shape. + #[cfg(target_os = "android")] + let addresses = conn.dump_addresses().unwrap_or_default(); + #[cfg(not(target_os = "android"))] + let addresses = conn.dump_addresses()?; + + let mut builders: HashMap = HashMap::new(); + for link in links { + let name = link.name.clone().unwrap_or_else(|| link.index.to_string()); + let mut builder = IfaceBuilder::new(name, link.index, link.flags); + builder.mac = link + .address + .as_deref() + .and_then(|mac| <[u8; 6]>::try_from(mac).ok()); + builders.insert(link.index, builder); + } + + for address in addresses { + // Addresses for unknown link indices are dropped. + let Some(builder) = builders.get_mut(&address.index) else { + continue; + }; + let Some(ip) = address.interface_address() else { + continue; + }; + match ip { + IpAddr::V4(ip) => { + if let Ok(net) = Ipv4Net::new(ip, address.prefix_len) { + builder.push_v4(net); + } + } + IpAddr::V6(ip) => { + if let Ok(net) = Ipv6Net::new(ip, address.prefix_len) { + let scope_id = resolve_v6_scope_id(&ip, 0, address.index); + let flags = from_netlink_flags(address.flags()); + builder.push_v6(net, scope_id, flags); + } + } + } + } + + Ok(builders.into_values().map(IfaceBuilder::finish).collect()) +} + +/// Collects the default-route gateways, keyed by output interface index. +/// +/// Only routes with a zero destination prefix and a gateway attribute +/// count; on-link default routes carry no gateway and are skipped. +#[allow(clippy::type_complexity)] +pub(super) fn default_gateways_by_interface() +-> Result, Vec)>, netwatch_netlink::Error> { + let mut conn = Connection::new()?; + let routes = conn.dump_routes(RouteFamily::Unspec)?; + + let mut gateways: HashMap, Vec)> = HashMap::new(); + for route in routes { + if route.dst_len != 0 { + continue; + } + let (Some(gateway), Some(oif)) = (route.gateway, route.oif) else { + continue; + }; + let (v4, v6) = gateways.entry(oif).or_default(); + match gateway { + IpAddr::V4(ip) if !v4.contains(&ip) => v4.push(ip), + IpAddr::V6(ip) if !v6.contains(&ip) => v6.push(ip), + _ => {} + } + } + Ok(gateways) +} + +/// Maps netlink `IFA_F_*` bits into [`Ipv6AddrFlags`]. +fn from_netlink_flags(raw: u32) -> Ipv6AddrFlags { + Ipv6AddrFlags { + deprecated: raw & libc::IFA_F_DEPRECATED != 0, + temporary: raw & libc::IFA_F_TEMPORARY != 0, + tentative: raw & libc::IFA_F_TENTATIVE != 0, + duplicated: raw & libc::IFA_F_DADFAILED != 0, + permanent: raw & libc::IFA_F_PERMANENT != 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_interfaces_contains_loopback() { + let ifaces = interfaces().unwrap(); + let lo = ifaces + .iter() + .find(|iface| iface.name == "lo") + .expect("no loopback interface"); + assert!(lo.index >= 1); + assert!(lo.is_up()); + assert!( + lo.addrs + .iter() + .any(|net| net.addr() == IpAddr::V4(Ipv4Addr::LOCALHOST)) + ); + } + + #[test] + fn test_flag_mapping() { + let flags = from_netlink_flags( + libc::IFA_F_DEPRECATED | libc::IFA_F_TEMPORARY | libc::IFA_F_PERMANENT, + ); + assert!(flags.deprecated); + assert!(flags.temporary); + assert!(flags.permanent); + assert!(!flags.tentative); + assert!(!flags.duplicated); + + assert_eq!(from_netlink_flags(0), Ipv6AddrFlags::default()); + } + + #[test] + fn test_default_gateways() { + // May be empty in an isolated namespace; only check it works. + let gateways = default_gateways_by_interface().unwrap(); + for (oif, (v4, v6)) in gateways { + assert_ne!(oif, 0); + assert!(!v4.is_empty() || !v6.is_empty()); + } + } +} diff --git a/netwatch/src/interfaces/enumerate/procfs.rs b/netwatch/src/interfaces/enumerate/procfs.rs new file mode 100644 index 00000000..4261ace2 --- /dev/null +++ b/netwatch/src/interfaces/enumerate/procfs.rs @@ -0,0 +1,118 @@ +//! `/proc/net` based gateway fallback, used when netlink route dumps fail. +//! +//! Mirrors netdev's procfs parser, including its quirks: the IPv4 pass +//! records the gateway of any gatewayed route (not just default routes), +//! later rows overwriting earlier ones per interface, while the IPv6 pass +//! only accepts real default routes. + +use std::{ + collections::HashMap, + net::{Ipv4Addr, Ipv6Addr}, +}; + +/// Collects gateway addresses per interface name. +pub(super) fn gateways_by_interface_name() -> HashMap, Vec)> { + let mut gateways: HashMap, Vec)> = HashMap::new(); + + if let Ok(routes) = std::fs::read_to_string("/proc/net/route") { + // Header line, then: Iface Destination Gateway Flags RefCnt Use + // Metric Mask MTU Window IRTT. Destination, gateway and mask are + // little-endian hex words. + for line in routes.lines().skip(1) { + let fields: Vec<&str> = line.split_ascii_whitespace().collect(); + let (Some(iface), Some(gateway)) = (fields.first(), fields.get(2)) else { + continue; + }; + if *gateway == "00000000" { + continue; + } + let Some(gateway) = parse_hex_ipv4_le(gateway) else { + continue; + }; + gateways.entry(iface.to_string()).or_default().0 = Some(gateway); + } + } + + if let Ok(routes) = std::fs::read_to_string("/proc/net/ipv6_route") { + // Fields: dest, dest prefix, source, source prefix, next hop, + // metric, refcnt, use, flags, device. + const ZERO_V6: &str = "00000000000000000000000000000000"; + for line in routes.lines() { + let fields: Vec<&str> = line.split_ascii_whitespace().collect(); + if fields.len() < 10 { + continue; + } + if fields[0] != ZERO_V6 || fields[1] != "00" || fields[4] == ZERO_V6 { + continue; + } + let Some(gateway) = parse_hex_ipv6(fields[4]) else { + continue; + }; + let iface = fields[9]; + gateways + .entry(iface.to_string()) + .or_default() + .1 + .push(gateway); + } + } + + gateways +} + +/// Parses an 8-digit little-endian hex word as used in `/proc/net/route`. +fn parse_hex_ipv4_le(hex: &str) -> Option { + if hex.len() != 8 { + return None; + } + let value = u32::from_str_radix(hex, 16).ok()?; + let [a, b, c, d] = value.to_le_bytes(); + Some(Ipv4Addr::new(a, b, c, d)) +} + +/// Parses a 32-digit hex address as used in `/proc/net/ipv6_route`. +fn parse_hex_ipv6(hex: &str) -> Option { + if hex.len() != 32 { + return None; + } + let mut octets = [0u8; 16]; + for (i, octet) in octets.iter_mut().enumerate() { + *octet = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?; + } + Some(Ipv6Addr::from(octets)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_hex_ipv4_le() { + // 192.168.0.1 in little-endian word order. + assert_eq!( + parse_hex_ipv4_le("0100A8C0"), + Some(Ipv4Addr::new(192, 168, 0, 1)) + ); + assert_eq!(parse_hex_ipv4_le("00000000"), Some(Ipv4Addr::UNSPECIFIED)); + assert_eq!(parse_hex_ipv4_le("123"), None); + } + + #[test] + fn test_parse_hex_ipv6() { + assert_eq!( + parse_hex_ipv6("fe800000000000000000000000000001"), + Some(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)) + ); + assert_eq!(parse_hex_ipv6("fe80"), None); + } + + #[test] + fn test_gateways_smoke() { + // Just verify /proc parsing does not panic on the live system. + let gateways = gateways_by_interface_name(); + for (name, (v4, v6)) in gateways { + assert!(!name.is_empty()); + assert!(v4.is_some() || !v6.is_empty()); + } + } +} From 65015db052bc46fd64d2ad6a5cf1aa61b2b9093f Mon Sep 17 00:00:00 2001 From: Frando Date: Mon, 17 Aug 2026 20:42:31 +0200 Subject: [PATCH 05/10] feat: inline BSD and Apple interface enumeration BSD and Apple platforms now enumerate through the inlined getifaddrs walk: MAC from AF_LINK sockaddr_dl entries, prefixes from (possibly truncated) netmask sockaddrs, scope IDs from sin6_scope_id with the link-local ifindex fallback, and per-address IPv6 flags from the SIOCGIFAFLAG_IN6 ioctl. netdev leaves all Apple and BSD builds, taking the plist, objc2 and SystemConfiguration dependency stack (and our msrv pins for it) with it. On OpenBSD and NetBSD the flag ioctl keeps netdev's behavior of failing softly to all-false flags. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 7 ------- netwatch/Cargo.toml | 13 +++---------- netwatch/src/interfaces/enumerate.rs | 23 ++++++++++++++++------- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index def5fa0c..53445dba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1175,8 +1175,6 @@ dependencies = [ "netdev", "netwatch-netlink", "noq-udp", - "objc2-core-foundation", - "objc2-system-configuration", "patchbay", "pin-project-lite", "serde", @@ -1383,7 +1381,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags", "objc2", "objc2-core-foundation", ] @@ -1405,11 +1402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ "bitflags", - "dispatch2", - "libc", - "objc2", "objc2-core-foundation", - "objc2-security", ] [[package]] diff --git a/netwatch/Cargo.toml b/netwatch/Cargo.toml index 62069d03..2e484a74 100644 --- a/netwatch/Cargo.toml +++ b/netwatch/Cargo.toml @@ -47,16 +47,6 @@ tokio = { version = "1", features = [ "io-std", ] } -# netdev remains on the platforms whose enumeration backend is not -# inlined yet; linux and android use netwatch-netlink and getifaddrs. -[target.'cfg(any(target_os = "windows", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "macos", target_os = "ios"))'.dependencies] -netdev = "0.45.0" - -[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] -# msrv fix -objc2-core-foundation = "0.3.2" -objc2-system-configuration = "0.3.2" - [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] netwatch-netlink = { version = "0.1.0", path = "../netwatch-netlink" } @@ -64,6 +54,9 @@ netwatch-netlink = { version = "0.1.0", path = "../netwatch-netlink" } tokio = { version = "1", features = ["process"] } [target.'cfg(target_os = "windows")'.dependencies] +# netdev remains only on windows, whose enumeration backend is not +# inlined yet; everything else uses the inlined backends. +netdev = "0.45.0" wmi = "0.18" windows = { version = "0.62.2", features = ["Win32_NetworkManagement_IpHelper", "Win32_Foundation", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock"] } windows-result = "0.4" diff --git a/netwatch/src/interfaces/enumerate.rs b/netwatch/src/interfaces/enumerate.rs index 03b74355..0b402388 100644 --- a/netwatch/src/interfaces/enumerate.rs +++ b/netwatch/src/interfaces/enumerate.rs @@ -9,14 +9,17 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; +#[cfg(any(target_os = "linux", target_os = "android", bsd))] use ipnet::{Ipv4Net, Ipv6Net}; -use super::{Interface, IpNet, Ipv6AddrFlags, State}; +use super::{Interface, State}; +#[cfg(any(target_os = "linux", target_os = "android", bsd))] +use super::{IpNet, Ipv6AddrFlags}; use crate::ip::{LocalAddresses, is_link_local, is_private, is_private_v6}; -#[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg(any(target_os = "linux", target_os = "android", bsd))] mod ifaddrs; -#[cfg(any(target_os = "windows", bsd))] +#[cfg(target_os = "windows")] mod netdev_shim; #[cfg(any(target_os = "linux", target_os = "android"))] mod netlink; @@ -48,7 +51,13 @@ pub(super) fn interfaces() -> Vec { } /// Enumerates the machine's network interfaces. -#[cfg(any(target_os = "windows", bsd))] +#[cfg(bsd)] +pub(super) fn interfaces() -> Vec { + ifaddrs::interfaces() +} + +/// Enumerates the machine's network interfaces. +#[cfg(target_os = "windows")] pub(super) fn interfaces() -> Vec { netdev_shim::interfaces() } @@ -99,7 +108,7 @@ fn default_gateway() -> Option { /// The push methods deduplicate addresses on (address, prefix) pairs the /// way netdev did, and `finish` produces the stable address order the /// state comparison relies on. -#[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg(any(target_os = "linux", target_os = "android", bsd))] #[derive(Debug)] struct IfaceBuilder { name: String, @@ -110,7 +119,7 @@ struct IfaceBuilder { v6: Vec<(Ipv6Net, u32, Ipv6AddrFlags)>, } -#[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg(any(target_os = "linux", target_os = "android", bsd))] impl IfaceBuilder { fn new(name: String, index: u32, flags: u32) -> Self { Self { @@ -176,7 +185,7 @@ impl IfaceBuilder { /// Prefers the scope reported by the OS and falls back to the interface /// index for link-local addresses, which is netdev behavior the rest of /// the code relies on. -#[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg(any(target_os = "linux", target_os = "android", bsd))] fn resolve_v6_scope_id(addr: &Ipv6Addr, raw_scope_id: u32, if_index: u32) -> u32 { if raw_scope_id != 0 { raw_scope_id From 08ec2bdac7b3f75c8d86c4747356fbecd064300b Mon Sep 17 00:00:00 2001 From: Frando Date: Mon, 17 Aug 2026 21:16:01 +0200 Subject: [PATCH 06/10] feat: inline windows interface enumeration, drop netdev Windows now enumerates adapters through GetAdaptersAddresses via the already-present windows crate, keeping netdev's behavior: every adapter is reported, flags are synthesized winsock IFF_* values, IPv6 address flags come from the DAD state and suffix origin, and the home router gateway requires an ARP-resolvable IPv4 gateway on the adapter owning the local IP. With the last backend inlined, netdev leaves the tree entirely and the netdev cfg alias becomes enumerate, named for the module it gates. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 341 +----------------- netwatch/Cargo.toml | 6 +- netwatch/build.rs | 7 +- netwatch/src/interfaces.rs | 7 +- netwatch/src/interfaces/enumerate.rs | 23 +- netwatch/src/interfaces/enumerate/adapters.rs | 286 +++++++++++++++ .../src/interfaces/enumerate/netdev_shim.rs | 87 ----- netwatch/src/interfaces/posix_minimal.rs | 3 +- netwatch/src/ip.rs | 4 +- 9 files changed, 316 insertions(+), 448 deletions(-) create mode 100644 netwatch/src/interfaces/enumerate/adapters.rs delete mode 100644 netwatch/src/interfaces/enumerate/netdev_shim.rs diff --git a/Cargo.lock b/Cargo.lock index 53445dba..e3c6c7f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,15 +73,6 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -110,12 +101,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -252,18 +237,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -275,17 +248,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dlopen2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" -dependencies = [ - "libc", - "once_cell", - "winapi", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -299,7 +261,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -527,7 +489,7 @@ dependencies = [ "data-encoding", "idna", "ipnet", - "jni 0.22.4", + "jni", "once_cell", "rand", "thiserror 2.0.18", @@ -823,22 +785,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - [[package]] name = "jni" version = "0.22.4" @@ -848,7 +794,7 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys 0.4.1", + "jni-sys", "log", "simd_cesu8", "thiserror 2.0.18", @@ -869,15 +815,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - [[package]] name = "jni-sys" version = "0.4.1" @@ -969,12 +906,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "mac-addr" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" - [[package]] name = "matchers" version = "0.2.0" @@ -1008,7 +939,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1064,39 +995,6 @@ dependencies = [ "n0-future", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "netdev" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" -dependencies = [ - "block2", - "dispatch2", - "dlopen2", - "ipnet", - "jni 0.21.1", - "libc", - "mac-addr", - "ndk-context", - "netlink-packet-core", - "netlink-packet-route 0.31.0", - "netlink-sys", - "objc2", - "objc2-core-foundation", - "objc2-core-wlan", - "objc2-foundation", - "objc2-system-configuration", - "once_cell", - "plist", - "windows-sys 0.61.2", -] - [[package]] name = "netlink-packet-core" version = "0.8.1" @@ -1118,18 +1016,6 @@ dependencies = [ "netlink-packet-core", ] -[[package]] -name = "netlink-packet-route" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" -dependencies = [ - "bitflags", - "libc", - "log", - "netlink-packet-core", -] - [[package]] name = "netlink-proto" version = "0.12.0" @@ -1172,7 +1058,6 @@ dependencies = [ "n0-error", "n0-future", "n0-watcher", - "netdev", "netwatch-netlink", "noq-udp", "patchbay", @@ -1237,7 +1122,7 @@ dependencies = [ "libc", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1279,7 +1164,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1320,91 +1205,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", - "block2", - "dispatch2", - "libc", - "objc2", -] - -[[package]] -name = "objc2-core-wlan" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-security", - "objc2-security-foundation", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-security-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "bitflags", - "objc2-core-foundation", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -1491,19 +1291,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "plist" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" -dependencies = [ - "base64", - "indexmap", - "quick-xml", - "serde", - "time", -] - [[package]] name = "portable-atomic" version = "1.13.1" @@ -1594,15 +1381,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", -] - [[package]] name = "quote" version = "1.0.45" @@ -1672,7 +1450,7 @@ dependencies = [ "futures-util", "log", "netlink-packet-core", - "netlink-packet-route 0.30.0", + "netlink-packet-route", "netlink-proto", "netlink-sys", "nix 0.30.1", @@ -1840,7 +1618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1997,7 +1775,6 @@ dependencies = [ "powerfmt", "serde_core", "time-core", - "time-macros", ] [[package]] @@ -2006,16 +1783,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" -[[package]] -name = "time-macros" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -2054,7 +1821,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -2459,37 +2226,15 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows" version = "0.62.2" @@ -2591,15 +2336,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2609,21 +2345,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - [[package]] name = "windows-threading" version = "0.2.1" @@ -2633,48 +2354,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "winnow" version = "1.0.3" diff --git a/netwatch/Cargo.toml b/netwatch/Cargo.toml index 2e484a74..778c3580 100644 --- a/netwatch/Cargo.toml +++ b/netwatch/Cargo.toml @@ -54,9 +54,6 @@ netwatch-netlink = { version = "0.1.0", path = "../netwatch-netlink" } tokio = { version = "1", features = ["process"] } [target.'cfg(target_os = "windows")'.dependencies] -# netdev remains only on windows, whose enumeration backend is not -# inlined yet; everything else uses the inlined backends. -netdev = "0.45.0" wmi = "0.18" windows = { version = "0.62.2", features = ["Win32_NetworkManagement_IpHelper", "Win32_Foundation", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock"] } windows-result = "0.4" @@ -102,8 +99,7 @@ rustdoc-args = ["--cfg", "iroh_docsrs"] # Types from these external crates are allowed to appear in netwatch's public # API. The list is enforced by `cargo check-external-types` (see the -# `check-external-types` task in Makefile.toml). Notably absent is `netdev`: -# its types must never leak, which is why the `interfaces` module mirrors them. +# `check-external-types` task in Makefile.toml). [package.metadata.cargo_check_external_types] allowed_external_types = [ # IP network types used by `interfaces::IpNet`. diff --git a/netwatch/build.rs b/netwatch/build.rs index 404d4748..6b537c14 100644 --- a/netwatch/build.rs +++ b/netwatch/build.rs @@ -7,10 +7,9 @@ fn main() { wasm_browser: { all(target_family = "wasm", target_os = "unknown") }, // Limited POSIX platforms (not wasm) posix_minimal: { target_os = "espidf" }, - // Platforms where the `netdev` crate is available, i.e. everything - // except esp-idf and wasm-in-browser. Keep in sync with the `netdev` - // dependency target gate in Cargo.toml. - netdev: { not(any(target_os = "espidf", all(target_family = "wasm", target_os = "unknown"))) }, + // Platforms with real interface enumeration (the `interfaces::enumerate` + // module), i.e. everything except esp-idf and wasm-in-browser. + enumerate: { not(any(target_os = "espidf", all(target_family = "wasm", target_os = "unknown"))) }, // BSD-derived platforms that share the `AF_ROUTE` routing-socket code. bsd: { any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "macos", target_os = "ios") }, } diff --git a/netwatch/src/interfaces.rs b/netwatch/src/interfaces.rs index edc5d9bc..8e14c726 100644 --- a/netwatch/src/interfaces.rs +++ b/netwatch/src/interfaces.rs @@ -21,7 +21,7 @@ use crate::ip::{LocalAddresses, is_link_local}; // Each platform module provides the same three entry points, reached through // the `platform` alias: `get_state()`, `default_route()` and `home_router()`. // The full platforms share enumeration and state assembly via `enumerate`. -#[cfg(netdev)] +#[cfg(enumerate)] mod enumerate; #[cfg(bsd)] @@ -54,9 +54,8 @@ const IFF_UP: u32 = 0x1; /// State flags for a single IPv6 address. /// -/// Hand-kept mirror of netdev's `Ipv6AddrFlags`, so the `interfaces` API is -/// identical on platforms built without `netdev` (e.g. esp-idf). All fields -/// default to `false` when the platform does not provide the information. +/// All fields default to `false` when the platform does not provide the +/// information (e.g. esp-idf, which has no interface enumeration). /// /// Flags are collected from platform-specific sources: /// diff --git a/netwatch/src/interfaces/enumerate.rs b/netwatch/src/interfaces/enumerate.rs index 0b402388..844c558c 100644 --- a/netwatch/src/interfaces/enumerate.rs +++ b/netwatch/src/interfaces/enumerate.rs @@ -4,23 +4,20 @@ //! from two platform primitives: `interfaces()`, the list of network //! interfaces in our own [`Interface`] type, and `default_gateway()`, the //! gateway address of the default route. BSD platforms do not use -//! `default_gateway()`; they parse the routing table in -//! [`crate::interfaces::bsd`] instead. +//! `default_gateway()`; they parse the routing table in the `bsd` module +//! of `crate::interfaces` instead. use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; -#[cfg(any(target_os = "linux", target_os = "android", bsd))] use ipnet::{Ipv4Net, Ipv6Net}; -use super::{Interface, State}; -#[cfg(any(target_os = "linux", target_os = "android", bsd))] -use super::{IpNet, Ipv6AddrFlags}; +use super::{Interface, IpNet, Ipv6AddrFlags, State}; use crate::ip::{LocalAddresses, is_link_local, is_private, is_private_v6}; +#[cfg(target_os = "windows")] +mod adapters; #[cfg(any(target_os = "linux", target_os = "android", bsd))] mod ifaddrs; -#[cfg(target_os = "windows")] -mod netdev_shim; #[cfg(any(target_os = "linux", target_os = "android"))] mod netlink; #[cfg(target_os = "linux")] @@ -59,7 +56,7 @@ pub(super) fn interfaces() -> Vec { /// Enumerates the machine's network interfaces. #[cfg(target_os = "windows")] pub(super) fn interfaces() -> Vec { - netdev_shim::interfaces() + adapters::interfaces() } /// The gateway address of the default route. @@ -100,7 +97,7 @@ fn default_gateway() -> Option { /// The gateway address of the default route. #[cfg(target_os = "windows")] fn default_gateway() -> Option { - netdev_shim::default_gateway() + adapters::default_gateway() } /// Accumulates one [`Interface`] during enumeration. @@ -108,7 +105,6 @@ fn default_gateway() -> Option { /// The push methods deduplicate addresses on (address, prefix) pairs the /// way netdev did, and `finish` produces the stable address order the /// state comparison relies on. -#[cfg(any(target_os = "linux", target_os = "android", bsd))] #[derive(Debug)] struct IfaceBuilder { name: String, @@ -119,7 +115,6 @@ struct IfaceBuilder { v6: Vec<(Ipv6Net, u32, Ipv6AddrFlags)>, } -#[cfg(any(target_os = "linux", target_os = "android", bsd))] impl IfaceBuilder { fn new(name: String, index: u32, flags: u32) -> Self { Self { @@ -234,8 +229,8 @@ pub(super) async fn get_state() -> State { /// The shared home-router lookup for linux, android and windows. /// -/// BSD platforms do not use this; they parse the routing table directly in -/// [`crate::interfaces::bsd`]. +/// BSD platforms do not use this; they parse the routing table directly +/// in the `bsd` module of `crate::interfaces`. #[cfg(any(target_os = "linux", target_os = "android", target_os = "windows"))] pub(super) fn home_router() -> Option { let gateway = default_gateway()?; diff --git a/netwatch/src/interfaces/enumerate/adapters.rs b/netwatch/src/interfaces/enumerate/adapters.rs new file mode 100644 index 00000000..7347b01f --- /dev/null +++ b/netwatch/src/interfaces/enumerate/adapters.rs @@ -0,0 +1,286 @@ +//! Windows interface enumeration via `GetAdaptersAddresses`. +//! +//! Mirrors what netdev did on windows: every adapter is reported +//! (loopback and tunnels included), interface flags are synthesized from +//! the operational status and interface type using winsock `IFF_*` +//! values, and the default gateway requires an ARP-resolvable IPv4 +//! gateway on the adapter owning the local IP. + +use std::{ + ffi::CStr, + net::{IpAddr, Ipv4Addr}, +}; + +use ipnet::{Ipv4Net, Ipv6Net}; +use windows::Win32::{ + Foundation::{ERROR_BUFFER_OVERFLOW, NO_ERROR}, + NetworkManagement::{ + IpHelper::{ + GAA_FLAG_INCLUDE_GATEWAYS, GetAdaptersAddresses, IP_ADAPTER_ADDRESSES_LH, + IP_ADAPTER_UNICAST_ADDRESS_LH, SendARP, + }, + Ndis::IfOperStatusUp, + }, + Networking::WinSock::{ + AF_INET, AF_INET6, AF_UNSPEC, IpDadStateDeprecated, IpDadStateDuplicate, + IpDadStateTentative, IpSuffixOriginRandom, SOCKADDR_INET, SOCKET_ADDRESS, + }, +}; + +use super::{IfaceBuilder, Interface, Ipv6AddrFlags}; + +// Winsock interface flag values; these differ from the POSIX ones +// (loopback is 0x4 here, 0x8 there). netdev synthesized flags from these +// values, so keeping them preserves the meaning of `Interface::flags` on +// windows. +const IFF_UP: u32 = 1; +const IFF_BROADCAST: u32 = 2; +const IFF_LOOPBACK: u32 = 4; +const IFF_POINTTOPOINT: u32 = 8; +const IFF_MULTICAST: u32 = 16; + +/// Enumerates the machine's network interfaces. +/// +/// Returns an empty list when the adapter query fails; there is no error +/// channel, matching the behavior callers have relied on so far. +pub(super) fn interfaces() -> Vec { + let Some(buf) = adapters_buffer() else { + return Vec::new(); + }; + + let mut interfaces = Vec::new(); + for adapter in iter_list(buf.as_ptr().cast::(), |a| { + a.Next.cast_const() + }) { + // SAFETY: AdapterName is a NUL-terminated ANSI string (the + // adapter GUID) owned by the buffer. + let name = unsafe { CStr::from_ptr(adapter.AdapterName.0.cast()) } + .to_string_lossy() + .into_owned(); + // SAFETY: reading the IfIndex variant of the union is always + // valid; both variants are plain integers. + let index = unsafe { adapter.Anonymous1.Anonymous.IfIndex }; + + let mut builder = IfaceBuilder::new(name, index, adapter_flags(adapter)); + if adapter.PhysicalAddressLength == 6 { + builder.mac = adapter.PhysicalAddress[..6].try_into().ok(); + } + + for unicast in iter_list( + adapter + .FirstUnicastAddress + .cast_const() + .cast::(), + |u| u.Next.cast_const(), + ) { + // SAFETY: the SOCKET_ADDRESS points into the adapter buffer. + let Some((ip, scope_id)) = (unsafe { socket_address_to_ip(&unicast.Address) }) else { + continue; + }; + match ip { + IpAddr::V4(ip) => { + // An out-of-range prefix drops the address, as it + // always has. + if let Ok(net) = Ipv4Net::new(ip, unicast.OnLinkPrefixLength) { + builder.push_v4(net); + } + } + IpAddr::V6(ip) => { + if let Ok(net) = Ipv6Net::new(ip, unicast.OnLinkPrefixLength) { + // No link-local ifindex fallback here: windows + // reports real scope IDs. + builder.push_v6(net, scope_id, unicast_v6_flags(unicast)); + } + } + } + } + + interfaces.push(builder.finish()); + } + interfaces +} + +/// The gateway address of the default route. +/// +/// netdev's rules, preserved: the adapter must own the local IP the OS +/// routes outbound traffic through, be up, and have an IPv4 gateway whose +/// MAC resolves via ARP; the first IPv4 gateway wins. An adapter with +/// only an IPv6 gateway yields nothing, like it did with netdev. +pub(super) fn default_gateway() -> Option { + let local_ip = super::local_ip()?; + let buf = adapters_buffer()?; + + for adapter in iter_list(buf.as_ptr().cast::(), |a| { + a.Next.cast_const() + }) { + if adapter_flags(adapter) & IFF_UP == 0 { + continue; + } + + let mut v4_addrs = Vec::new(); + let mut owns_local_ip = false; + for unicast in iter_list( + adapter + .FirstUnicastAddress + .cast_const() + .cast::(), + |u| u.Next.cast_const(), + ) { + // SAFETY: the SOCKET_ADDRESS points into the adapter buffer. + let Some((ip, _)) = (unsafe { socket_address_to_ip(&unicast.Address) }) else { + continue; + }; + owns_local_ip |= ip == local_ip; + if let IpAddr::V4(ip) = ip { + v4_addrs.push(ip); + } + } + if !owns_local_ip { + continue; + } + + for gateway in iter_list(adapter.FirstGatewayAddress.cast_const(), |g| { + g.Next.cast_const() + }) { + // SAFETY: as above. + let Some((IpAddr::V4(gateway), _)) = + (unsafe { socket_address_to_ip(&gateway.Address) }) + else { + continue; + }; + let Some(src) = v4_addrs.first() else { + continue; + }; + if arp_resolves(*src, gateway) { + return Some(IpAddr::V4(gateway)); + } + } + } + None +} + +/// Queries the adapter list, growing the buffer up to three times as +/// `GetAdaptersAddresses` requests. +fn adapters_buffer() -> Option> { + // 15k is the size MSDN recommends to avoid the second call. + let mut buf: Vec = Vec::with_capacity(15000); + let mut retries = 3; + loop { + let mut size = buf.capacity() as u32; + // SAFETY: the buffer is valid for writes of `size` bytes. + let res = unsafe { + GetAdaptersAddresses( + AF_UNSPEC.0 as u32, + GAA_FLAG_INCLUDE_GATEWAYS, + None, + Some(buf.as_mut_ptr().cast()), + &mut size, + ) + }; + if res == NO_ERROR.0 { + // SAFETY: the call wrote `size` bytes (bounded by capacity). + unsafe { buf.set_len(size as usize) }; + return Some(buf); + } else if res == ERROR_BUFFER_OVERFLOW.0 && retries > 0 { + buf.reserve(size as usize); + retries -= 1; + } else { + return None; + } + } +} + +/// Iterates a `Next`-linked list of structs inside the adapter buffer. +fn iter_list<'a, T: 'a>( + mut ptr: *const T, + next: fn(&T) -> *const T, +) -> impl Iterator { + std::iter::from_fn(move || { + // SAFETY: the pointer is null or points into the live adapter + // buffer the caller borrows from. + let current = unsafe { ptr.as_ref() }?; + ptr = next(current); + Some(current) + }) +} + +/// Synthesizes BSD-style flags from the adapter state and type. +fn adapter_flags(adapter: &IP_ADAPTER_ADDRESSES_LH) -> u32 { + let mut flags = 0; + if adapter.OperStatus == IfOperStatusUp { + flags |= IFF_UP; + } + // Raw IANA ifType values, as netdev matched them. + flags |= match adapter.IfType { + // ethernet, token ring, 802.11 wireless, IEEE 1394 + 6 | 9 | 71 | 144 => IFF_BROADCAST | IFF_MULTICAST, + // PPP, tunnel + 23 | 131 => IFF_POINTTOPOINT | IFF_MULTICAST, + // software loopback + 24 => IFF_LOOPBACK | IFF_MULTICAST, + // ATM + 37 => IFF_BROADCAST | IFF_POINTTOPOINT | IFF_MULTICAST, + _ => 0, + }; + flags +} + +/// Maps the address' duplicate-address-detection state and suffix origin +/// into [`Ipv6AddrFlags`]. +fn unicast_v6_flags(unicast: &IP_ADAPTER_UNICAST_ADDRESS_LH) -> Ipv6AddrFlags { + Ipv6AddrFlags { + deprecated: unicast.DadState == IpDadStateDeprecated, + temporary: unicast.SuffixOrigin == IpSuffixOriginRandom, + tentative: unicast.DadState == IpDadStateTentative, + duplicated: unicast.DadState == IpDadStateDuplicate, + // Not reported on windows. + permanent: false, + } +} + +/// Parses a `SOCKET_ADDRESS`, returning the address and, for IPv6, the +/// scope ID. +/// +/// # Safety +/// +/// `address.lpSockaddr` must be null or point to a sockaddr at least as +/// large as its family's `SOCKADDR_IN`/`SOCKADDR_IN6`, as the adapter +/// buffer guarantees. +unsafe fn socket_address_to_ip(address: &SOCKET_ADDRESS) -> Option<(IpAddr, u32)> { + // SAFETY: per the caller contract. + let sockaddr = unsafe { address.lpSockaddr.cast::().as_ref() }?; + // SAFETY: si_family overlaps the family field of both variants. + let family = unsafe { sockaddr.si_family }; + if family == AF_INET { + // SAFETY: family says this is a SOCKADDR_IN. + let octets = unsafe { sockaddr.Ipv4.sin_addr.S_un.S_addr }.to_ne_bytes(); + Some((IpAddr::V4(Ipv4Addr::from(octets)), 0)) + } else if family == AF_INET6 { + // SAFETY: family says this is a SOCKADDR_IN6. + let ip = IpAddr::from(unsafe { sockaddr.Ipv6.sin6_addr.u.Byte }); + // SAFETY: both union variants are a u32. + let scope_id = unsafe { sockaddr.Ipv6.Anonymous.sin6_scope_id }; + Some((ip, scope_id)) + } else { + None + } +} + +/// Reports whether the gateway's MAC address resolves via ARP. +/// +/// netdev used the resolved MAC only as a liveness gate for the gateway, +/// and so do we. +fn arp_resolves(src: Ipv4Addr, dst: Ipv4Addr) -> bool { + let mut mac = [0u8; 6]; + let mut mac_len = mac.len() as u32; + // SAFETY: `mac` is valid for writes of `mac_len` bytes. + let res = unsafe { + SendARP( + u32::from_ne_bytes(dst.octets()), + u32::from_ne_bytes(src.octets()), + mac.as_mut_ptr().cast(), + &mut mac_len, + ) + }; + res == NO_ERROR.0 && mac_len == 6 && mac != [0u8; 6] +} diff --git a/netwatch/src/interfaces/enumerate/netdev_shim.rs b/netwatch/src/interfaces/enumerate/netdev_shim.rs deleted file mode 100644 index 7ccd47de..00000000 --- a/netwatch/src/interfaces/enumerate/netdev_shim.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Temporary enumeration backend that converts from the `netdev` crate. -//! -//! Platforms move to inlined backends one by one; this shim disappears -//! when the last one (windows) lands. Nothing it produces exposes a -//! `netdev` type, which is enforced by `cargo check-external-types`. - -#[cfg(any(target_os = "linux", target_os = "android", target_os = "windows"))] -use std::net::IpAddr; - -use super::super::{Interface, IpNet, Ipv6AddrFlags}; - -/// Converts netdev's IPv6 address flags into our mirrored [`Ipv6AddrFlags`]. -/// -/// This is a free function rather than a `From` impl on purpose: a public -/// `From` would re-expose the `netdev` type in our public API, -/// which is exactly what the [`Ipv6AddrFlags`] mirror exists to avoid. -fn to_ipv6_addr_flags(flags: netdev::interface::ipv6_addr_flags::Ipv6AddrFlags) -> Ipv6AddrFlags { - Ipv6AddrFlags { - deprecated: flags.deprecated, - temporary: flags.temporary, - tentative: flags.tentative, - duplicated: flags.duplicated, - permanent: flags.permanent, - } -} - -/// Converts a [`netdev::Interface`] into our platform-agnostic [`Interface`]. -/// -/// Addresses are sorted (IPv4 first, then IPv6, each by address) so that -/// comparisons between successive snapshots are stable. -fn to_interface(iface: netdev::Interface) -> Interface { - // netdev keeps these three IPv6 arrays parallel, one entry per address. - // The zip below relies on that; assert it so a netdev change that breaks - // the invariant surfaces in tests rather than silently dropping addresses. - debug_assert_eq!(iface.ipv6.len(), iface.ipv6_scope_ids.len()); - debug_assert_eq!(iface.ipv6.len(), iface.ipv6_addr_flags.len()); - - let mut v4: Vec = iface.ipv4.iter().copied().map(IpNet::V4).collect(); - let mut v6: Vec = iface - .ipv6 - .iter() - .copied() - .zip(iface.ipv6_scope_ids.iter().copied()) - .zip(iface.ipv6_addr_flags.iter().copied()) - .map(|((net, scope_id), flags)| IpNet::V6 { - net, - scope_id, - flags: to_ipv6_addr_flags(flags), - }) - .collect(); - - // Sort each family by address so successive snapshots compare equal, then - // concatenate as IPv4-first. - v4.sort_by_key(IpNet::addr); - v6.sort_by_key(IpNet::addr); - let mut addrs = v4; - addrs.append(&mut v6); - - Interface { - name: iface.name, - index: iface.index, - flags: iface.flags, - mac_addr: iface.mac_addr.as_ref().map(|a| a.octets()), - addrs, - } -} - -/// Enumerates the machine's network interfaces. -pub(super) fn interfaces() -> Vec { - netdev::interface::get_interfaces() - .into_iter() - .map(to_interface) - .collect() -} - -/// The gateway address of the default route, as reported by `netdev`. -#[cfg(any(target_os = "linux", target_os = "android", target_os = "windows"))] -pub(super) fn default_gateway() -> Option { - let gateway = netdev::get_default_gateway().ok()?; - gateway - .ipv4 - .iter() - .copied() - .map(IpAddr::V4) - .chain(gateway.ipv6.iter().copied().map(IpAddr::V6)) - .next() -} diff --git a/netwatch/src/interfaces/posix_minimal.rs b/netwatch/src/interfaces/posix_minimal.rs index a0559f5c..b476d1a4 100644 --- a/netwatch/src/interfaces/posix_minimal.rs +++ b/netwatch/src/interfaces/posix_minimal.rs @@ -1,4 +1,5 @@ -//! Interface lookups for POSIX platforms without `netdev` (e.g. esp-idf). +//! Interface lookups for POSIX platforms without interface enumeration +//! (e.g. esp-idf). //! //! No interface enumeration, default route, or home router is available on //! these platforms, so every lookup reports empty or absent. diff --git a/netwatch/src/ip.rs b/netwatch/src/ip.rs index 7d7eda3a..0f18aa79 100644 --- a/netwatch/src/ip.rs +++ b/netwatch/src/ip.rs @@ -18,7 +18,7 @@ pub struct LocalAddresses { /// Reports whether `ip` is a private address, according to RFC 1918 /// (IPv4 addresses) and RFC 4193 (IPv6 addresses). That is, it reports whether /// ip is in 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, or fc00::/7. -#[cfg(netdev)] +#[cfg(enumerate)] pub(crate) fn is_private(ip: &IpAddr) -> bool { match ip { IpAddr::V4(ip) => { @@ -33,7 +33,7 @@ pub(crate) fn is_private(ip: &IpAddr) -> bool { } } -#[cfg(netdev)] +#[cfg(enumerate)] pub(crate) fn is_private_v6(ip: &Ipv6Addr) -> bool { // RFC 4193 allocates fc00::/7 as the unique local unicast IPv6 address subnet. ip.octets()[0] & 0xfe == 0xfc From cd03740587e2c8fd088d18fefc38418719acd969 Mon Sep 17 00:00:00 2001 From: Frando Date: Mon, 17 Aug 2026 21:20:06 +0200 Subject: [PATCH 07/10] fix: review findings from the netdev inlining Match netdev's gateway selection exactly: the first interface owning the local IP that also has a gateway wins, instead of only consulting the first owner. Annotate the BSD default_route as intentionally async (cross-platform contract), and add netwatch-netlink to the CI feature check list. Co-Authored-By: Claude Fable 5 --- .github/workflows/tests.yaml | 2 +- netwatch/src/interfaces/bsd.rs | 3 +++ netwatch/src/interfaces/enumerate.rs | 33 ++++++++++++++++++---------- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 9ce0c5be..6f1b693d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -23,7 +23,7 @@ env: RUSTFLAGS: -Dwarnings RUSTDOCFLAGS: -Dwarnings SCCACHE_CACHE_SIZE: "10G" - CRATES_LIST: "netwatch,portmapper" + CRATES_LIST: "netwatch,netwatch-netlink,portmapper" IROH_FORCE_STAGING_RELAYS: "1" jobs: diff --git a/netwatch/src/interfaces/bsd.rs b/netwatch/src/interfaces/bsd.rs index bd084b6f..46989a0c 100644 --- a/netwatch/src/interfaces/bsd.rs +++ b/netwatch/src/interfaces/bsd.rs @@ -37,6 +37,9 @@ mod macos; #[cfg(any(target_os = "macos", target_os = "ios"))] use self::macos::*; +// The signature is part of the cross-platform contract; other platforms +// genuinely await in their implementations. +#[allow(clippy::unused_async)] pub async fn default_route() -> Option { let idx = default_route_interface_index()?; let interfaces = super::enumerate::interfaces(); diff --git a/netwatch/src/interfaces/enumerate.rs b/netwatch/src/interfaces/enumerate.rs index 844c558c..4b7477e4 100644 --- a/netwatch/src/interfaces/enumerate.rs +++ b/netwatch/src/interfaces/enumerate.rs @@ -69,23 +69,32 @@ pub(super) fn interfaces() -> Vec { fn default_gateway() -> Option { let local_ip = local_ip()?; let ifaces = interfaces(); - let iface = ifaces + // The first interface owning the local IP that also has a gateway + // wins, like in netdev's algorithm. + let owners = ifaces .iter() - .find(|iface| iface.addrs.iter().any(|net| net.addr() == local_ip))?; + .filter(|iface| iface.addrs.iter().any(|net| net.addr() == local_ip)); match netlink::default_gateways_by_interface() { - Ok(mut gateways) => { - let (v4, v6) = gateways.remove(&iface.index)?; - v4.first() - .copied() - .map(IpAddr::V4) - .or_else(|| v6.first().copied().map(IpAddr::V6)) - } + Ok(mut gateways) => owners + .filter_map(|iface| { + let (v4, v6) = gateways.remove(&iface.index)?; + v4.first() + .copied() + .map(IpAddr::V4) + .or_else(|| v6.first().copied().map(IpAddr::V6)) + }) + .next(), #[cfg(target_os = "linux")] Err(err) => { tracing::debug!("netlink route dump failed ({err:?}), trying procfs"); - let (v4, v6) = procfs::gateways_by_interface_name().remove(iface.name())?; - v4.map(IpAddr::V4) - .or_else(|| v6.first().copied().map(IpAddr::V6)) + let mut gateways = procfs::gateways_by_interface_name(); + owners + .filter_map(|iface| { + let (v4, v6) = gateways.remove(iface.name())?; + v4.map(IpAddr::V4) + .or_else(|| v6.first().copied().map(IpAddr::V6)) + }) + .next() } // Android has no readable /proc/net/route; without netlink there // is no gateway source. From b71830a6f0b286a4e456a6b0972d5d56c136bfaf Mon Sep 17 00:00:00 2001 From: Frando Date: Mon, 17 Aug 2026 21:37:00 +0200 Subject: [PATCH 08/10] refactor: shrink and isolate the unsafe surface The netlink socket now goes through socket2 instead of raw libc calls, leaving two one-line unsafe blocks (the sockaddr_nl construction and the MaybeUninit buffer cast for recv); the manual poll loop is replaced by a receive timeout. The getifaddrs walk moves behind an RAII IfAddrs list with Entry accessors, making the enumeration logic itself safe and fixing a leak of the list when the walk panicked; the flag ioctl now encodes its request bytes directly instead of unaligned struct writes, leaving the ioctl call as its only unsafe. The windows backend gets the same treatment with an Adapters buffer type whose iterators tie adapter references to the buffer lifetime. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + netwatch-netlink/Cargo.toml | 1 + netwatch-netlink/src/conn.rs | 168 +++++++-------- netwatch/src/interfaces/enumerate/adapters.rs | 192 +++++++++++------- netwatch/src/interfaces/enumerate/ifaddrs.rs | 184 +++++++++++------ 5 files changed, 315 insertions(+), 231 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e3c6c7f9..24078867 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,6 +1084,7 @@ version = "0.1.0" dependencies = [ "libc", "n0-error", + "socket2", "tokio", "tracing", ] diff --git a/netwatch-netlink/Cargo.toml b/netwatch-netlink/Cargo.toml index 1a7acd93..a9e1787a 100644 --- a/netwatch-netlink/Cargo.toml +++ b/netwatch-netlink/Cargo.toml @@ -20,6 +20,7 @@ workspace = true [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] libc = "0.2.139" n0-error = "1.0.0" +socket2 = "0.6" tokio = { version = "1", features = ["net", "time"] } tracing = "0.1" diff --git a/netwatch-netlink/src/conn.rs b/netwatch-netlink/src/conn.rs index f8e057c5..3e9ea069 100644 --- a/netwatch-netlink/src/conn.rs +++ b/netwatch-netlink/src/conn.rs @@ -4,11 +4,13 @@ use std::{ collections::VecDeque, io, - os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}, + mem::MaybeUninit, + os::fd::{AsRawFd, RawFd}, time::{Duration, Instant}, }; use n0_error::e; +use socket2::{Domain, Protocol, SockAddr, Socket, Type}; use tokio::io::unix::AsyncFd; use tracing::warn; @@ -33,29 +35,44 @@ const RECV_BUF_SIZE: usize = 64 * 1024; /// datagram. const DUMP_TIMEOUT: Duration = Duration::from_secs(2); -/// A non-blocking `NETLINK_ROUTE` socket. +/// Builds the netlink socket address for the given multicast group mask. +/// +/// A zero mask addresses the kernel for request/response use. +fn netlink_addr(groups: u32) -> SockAddr { + // SAFETY: an all-zero sockaddr_nl is valid; only the family and the + // group mask need real values (pid zero lets the kernel assign one). + // The storage is larger than sockaddr_nl and the length says so. + unsafe { + let mut storage = socket2::SockAddrStorage::zeroed(); + let addr = std::ptr::from_mut(&mut storage).cast::(); + (*addr).nl_family = libc::AF_NETLINK as libc::sa_family_t; + (*addr).nl_groups = groups; + SockAddr::new( + storage, + std::mem::size_of::() as libc::socklen_t, + ) + } +} + +/// A `NETLINK_ROUTE` socket. #[derive(Debug)] struct NetlinkSocket { - fd: OwnedFd, + socket: Socket, } impl NetlinkSocket { /// Opens the socket, subscribed to the multicast groups in `groups` /// (zero for request/response use). - fn new(groups: u32) -> io::Result { - let fd = unsafe { - libc::socket( - libc::AF_NETLINK, - libc::SOCK_DGRAM | libc::SOCK_CLOEXEC | libc::SOCK_NONBLOCK, - libc::NETLINK_ROUTE, - ) - }; - if fd < 0 { - return Err(io::Error::last_os_error()); - } - // SAFETY: `fd` is a freshly created socket owned by no one else. - let fd = unsafe { OwnedFd::from_raw_fd(fd) }; - let socket = Self { fd }; + /// + /// Blocking receives use a per-call timeout; async users flip the + /// socket to non-blocking and drive it through an [`AsyncFd`]. + fn new(groups: u32, nonblocking: bool) -> io::Result { + let socket = Socket::new( + Domain::from(libc::AF_NETLINK), + Type::DGRAM, + Some(Protocol::from(libc::NETLINK_ROUTE)), + )?; + socket.set_nonblocking(nonblocking)?; // On Android 11+ SELinux denies bind on netlink route sockets for // apps; the kernel auto-binds on the first send instead. Group @@ -63,47 +80,16 @@ impl NetlinkSocket { // (netmon is a no-op there). let bind = groups != 0 || cfg!(not(target_os = "android")); if bind { - // SAFETY: sockaddr_nl is valid when zeroed. - let mut addr: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; - addr.nl_family = libc::AF_NETLINK as libc::sa_family_t; - addr.nl_groups = groups; - // SAFETY: `addr` is a valid sockaddr_nl and outlives the call. - let res = unsafe { - libc::bind( - socket.fd.as_raw_fd(), - std::ptr::from_ref(&addr).cast(), - std::mem::size_of::() as libc::socklen_t, - ) - }; - if res < 0 { - return Err(io::Error::last_os_error()); - } + socket.bind(&netlink_addr(groups))?; } - Ok(socket) + Ok(Self { socket }) } /// Sends a request datagram to the kernel. fn send_request(&self, buf: &[u8]) -> io::Result<()> { - // SAFETY: sockaddr_nl is valid when zeroed; pid and groups zero - // address the kernel. - let mut addr: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; - addr.nl_family = libc::AF_NETLINK as libc::sa_family_t; - // SAFETY: `buf` and `addr` are valid for the duration of the call. - let res = unsafe { - libc::sendto( - self.fd.as_raw_fd(), - buf.as_ptr().cast(), - buf.len(), - 0, - std::ptr::from_ref(&addr).cast(), - std::mem::size_of::() as libc::socklen_t, - ) - }; - if res < 0 { - return Err(io::Error::last_os_error()); - } + let sent = self.socket.send_to(buf, &netlink_addr(0))?; // Datagram sockets send whole messages; a short send cannot happen. - debug_assert_eq!(res as usize, buf.len()); + debug_assert_eq!(sent, buf.len()); Ok(()) } @@ -112,46 +98,35 @@ impl NetlinkSocket { /// Returns the datagram's true length, which exceeds `buf.len()` when /// the datagram was truncated (`MSG_TRUNC`). fn recv(&self, buf: &mut [u8]) -> io::Result { - // SAFETY: `buf` is valid for writes of `buf.len()` bytes. - let res = unsafe { - libc::recv( - self.fd.as_raw_fd(), - buf.as_mut_ptr().cast(), - buf.len(), - libc::MSG_TRUNC, - ) - }; - if res < 0 { - return Err(io::Error::last_os_error()); - } - Ok(res as usize) + // SAFETY: MaybeUninit has the same layout as u8, and the + // receive only writes initialized bytes into the buffer. + let buf = unsafe { &mut *(std::ptr::from_mut::<[u8]>(buf) as *mut [MaybeUninit]) }; + self.socket.recv_with_flags(buf, libc::MSG_TRUNC) } - /// Waits until the socket is readable or `deadline` passes. + /// Blocking receive of one datagram, bounded by `deadline`. /// - /// Returns `false` on timeout. - fn poll_readable(&self, deadline: Instant) -> io::Result { + /// Returns `None` when the deadline passes first. + fn recv_deadline(&self, buf: &mut [u8], deadline: Instant) -> io::Result> { loop { let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { - return Ok(false); - }; - let timeout_ms = remaining.as_millis().min(i32::MAX as u128 - 1) as i32 + 1; - let mut pollfd = libc::pollfd { - fd: self.fd.as_raw_fd(), - events: libc::POLLIN, - revents: 0, + return Ok(None); }; - // SAFETY: `pollfd` is a valid pollfd array of length one. - let res = unsafe { libc::poll(&mut pollfd, 1, timeout_ms) }; - match res { - -1 => { - let err = io::Error::last_os_error(); - if err.kind() != io::ErrorKind::Interrupted { - return Err(err); - } + // A zero timeout would mean "block forever". + self.socket + .set_read_timeout(Some(remaining.max(Duration::from_millis(1))))?; + match self.recv(buf) { + Ok(len) => return Ok(Some(len)), + Err(err) if err.kind() == io::ErrorKind::Interrupted => {} + Err(err) + if matches!( + err.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => + { + return Ok(None); } - 0 => return Ok(false), - _ => return Ok(true), + Err(err) => return Err(err), } } } @@ -159,7 +134,7 @@ impl NetlinkSocket { impl AsRawFd for NetlinkSocket { fn as_raw_fd(&self) -> RawFd { - self.fd.as_raw_fd() + self.socket.as_raw_fd() } } @@ -245,7 +220,7 @@ impl Connection { /// Opens a new connection. pub fn new() -> Result { Ok(Self { - socket: NetlinkSocket::new(0)?, + socket: NetlinkSocket::new(0, false)?, seq: 0, buf: vec![0; RECV_BUF_SIZE], }) @@ -278,14 +253,13 @@ impl Connection { let deadline = Instant::now() + DUMP_TIMEOUT; let mut collector = DumpCollector::default(); while !collector.done { - if !self.socket.poll_readable(deadline)? { - warn!("netlink dump timed out, returning partial result"); - break; - } - match self.socket.recv(&mut self.buf) { - Ok(len) if len > self.buf.len() => return Err(e!(Error::Truncated)), - Ok(len) => collector.push_datagram(seq, &self.buf[..len])?, - Err(err) if err.kind() == io::ErrorKind::WouldBlock => {} + match self.socket.recv_deadline(&mut self.buf, deadline) { + Ok(None) => { + warn!("netlink dump timed out, returning partial result"); + break; + } + Ok(Some(len)) if len > self.buf.len() => return Err(e!(Error::Truncated)), + Ok(Some(len)) => collector.push_datagram(seq, &self.buf[..len])?, Err(err) => return Err(map_recv_err(err)), } } @@ -308,7 +282,7 @@ impl AsyncConnection { /// /// Must be called from within a tokio runtime. pub fn new() -> Result { - let socket = NetlinkSocket::new(0)?; + let socket = NetlinkSocket::new(0, true)?; Ok(Self { socket: AsyncFd::new(socket).map_err(|err| e!(Error::Io, err))?, seq: 0, @@ -433,7 +407,7 @@ impl EventSocket { /// /// Must be called from within a tokio runtime. pub fn subscribe(groups: u32) -> Result { - let socket = NetlinkSocket::new(groups)?; + let socket = NetlinkSocket::new(groups, true)?; Ok(Self { socket: AsyncFd::new(socket).map_err(|err| e!(Error::Io, err))?, buf: vec![0; RECV_BUF_SIZE], diff --git a/netwatch/src/interfaces/enumerate/adapters.rs b/netwatch/src/interfaces/enumerate/adapters.rs index 7347b01f..4d0b512d 100644 --- a/netwatch/src/interfaces/enumerate/adapters.rs +++ b/netwatch/src/interfaces/enumerate/adapters.rs @@ -5,6 +5,9 @@ //! the operational status and interface type using winsock `IFF_*` //! values, and the default gateway requires an ARP-resolvable IPv4 //! gateway on the adapter owning the local IP. +//! +//! The unsafe surface is confined to the [`Adapters`] buffer wrapper and +//! its accessors; the enumeration logic itself is safe code. use std::{ ffi::CStr, @@ -17,7 +20,7 @@ use windows::Win32::{ NetworkManagement::{ IpHelper::{ GAA_FLAG_INCLUDE_GATEWAYS, GetAdaptersAddresses, IP_ADAPTER_ADDRESSES_LH, - IP_ADAPTER_UNICAST_ADDRESS_LH, SendARP, + IP_ADAPTER_GATEWAY_ADDRESS_LH, IP_ADAPTER_UNICAST_ADDRESS_LH, SendARP, }, Ndis::IfOperStatusUp, }, @@ -44,37 +47,23 @@ const IFF_MULTICAST: u32 = 16; /// Returns an empty list when the adapter query fails; there is no error /// channel, matching the behavior callers have relied on so far. pub(super) fn interfaces() -> Vec { - let Some(buf) = adapters_buffer() else { + let Some(adapters) = Adapters::load() else { return Vec::new(); }; let mut interfaces = Vec::new(); - for adapter in iter_list(buf.as_ptr().cast::(), |a| { - a.Next.cast_const() - }) { - // SAFETY: AdapterName is a NUL-terminated ANSI string (the - // adapter GUID) owned by the buffer. - let name = unsafe { CStr::from_ptr(adapter.AdapterName.0.cast()) } - .to_string_lossy() - .into_owned(); - // SAFETY: reading the IfIndex variant of the union is always - // valid; both variants are plain integers. - let index = unsafe { adapter.Anonymous1.Anonymous.IfIndex }; - - let mut builder = IfaceBuilder::new(name, index, adapter_flags(adapter)); + for adapter in adapters.iter() { + let mut builder = IfaceBuilder::new( + adapter_name(adapter), + adapter_index(adapter), + adapter_flags(adapter), + ); if adapter.PhysicalAddressLength == 6 { builder.mac = adapter.PhysicalAddress[..6].try_into().ok(); } - for unicast in iter_list( - adapter - .FirstUnicastAddress - .cast_const() - .cast::(), - |u| u.Next.cast_const(), - ) { - // SAFETY: the SOCKET_ADDRESS points into the adapter buffer. - let Some((ip, scope_id)) = (unsafe { socket_address_to_ip(&unicast.Address) }) else { + for unicast in unicast_addrs(adapter) { + let Some((ip, scope_id)) = socket_address_to_ip(adapter, &unicast.Address) else { continue; }; match ip { @@ -108,26 +97,17 @@ pub(super) fn interfaces() -> Vec { /// only an IPv6 gateway yields nothing, like it did with netdev. pub(super) fn default_gateway() -> Option { let local_ip = super::local_ip()?; - let buf = adapters_buffer()?; + let adapters = Adapters::load()?; - for adapter in iter_list(buf.as_ptr().cast::(), |a| { - a.Next.cast_const() - }) { + for adapter in adapters.iter() { if adapter_flags(adapter) & IFF_UP == 0 { continue; } let mut v4_addrs = Vec::new(); let mut owns_local_ip = false; - for unicast in iter_list( - adapter - .FirstUnicastAddress - .cast_const() - .cast::(), - |u| u.Next.cast_const(), - ) { - // SAFETY: the SOCKET_ADDRESS points into the adapter buffer. - let Some((ip, _)) = (unsafe { socket_address_to_ip(&unicast.Address) }) else { + for unicast in unicast_addrs(adapter) { + let Some((ip, _)) = socket_address_to_ip(adapter, &unicast.Address) else { continue; }; owns_local_ip |= ip == local_ip; @@ -139,12 +119,8 @@ pub(super) fn default_gateway() -> Option { continue; } - for gateway in iter_list(adapter.FirstGatewayAddress.cast_const(), |g| { - g.Next.cast_const() - }) { - // SAFETY: as above. - let Some((IpAddr::V4(gateway), _)) = - (unsafe { socket_address_to_ip(&gateway.Address) }) + for gateway in gateway_addrs(adapter) { + let Some((IpAddr::V4(gateway), _)) = socket_address_to_ip(adapter, &gateway.Address) else { continue; }; @@ -159,38 +135,98 @@ pub(super) fn default_gateway() -> Option { None } -/// Queries the adapter list, growing the buffer up to three times as -/// `GetAdaptersAddresses` requests. -fn adapters_buffer() -> Option> { - // 15k is the size MSDN recommends to avoid the second call. - let mut buf: Vec = Vec::with_capacity(15000); - let mut retries = 3; - loop { - let mut size = buf.capacity() as u32; - // SAFETY: the buffer is valid for writes of `size` bytes. - let res = unsafe { - GetAdaptersAddresses( - AF_UNSPEC.0 as u32, - GAA_FLAG_INCLUDE_GATEWAYS, - None, - Some(buf.as_mut_ptr().cast()), - &mut size, - ) - }; - if res == NO_ERROR.0 { - // SAFETY: the call wrote `size` bytes (bounded by capacity). - unsafe { buf.set_len(size as usize) }; - return Some(buf); - } else if res == ERROR_BUFFER_OVERFLOW.0 && retries > 0 { - buf.reserve(size as usize); - retries -= 1; - } else { - return None; +/// The adapter list returned by `GetAdaptersAddresses`. +/// +/// Owns the backing buffer; all adapter references and the pointers +/// inside them stay valid for as long as this value lives, which is what +/// the accessor functions below rely on. +struct Adapters { + buf: Vec, +} + +impl Adapters { + /// Queries the adapter list, growing the buffer up to three times as + /// `GetAdaptersAddresses` requests. + fn load() -> Option { + // 15k is the size MSDN recommends to avoid the second call. + let mut buf: Vec = Vec::with_capacity(15000); + let mut retries = 3; + loop { + let mut size = buf.capacity() as u32; + // SAFETY: the buffer is valid for writes of `size` bytes, and + // on success the call wrote `size` bytes (bounded by the + // capacity). + let res = unsafe { + let res = GetAdaptersAddresses( + AF_UNSPEC.0 as u32, + GAA_FLAG_INCLUDE_GATEWAYS, + None, + Some(buf.as_mut_ptr().cast()), + &mut size, + ); + if res == NO_ERROR.0 { + buf.set_len(size as usize); + } + res + }; + if res == NO_ERROR.0 { + return Some(Self { buf }); + } else if res == ERROR_BUFFER_OVERFLOW.0 && retries > 0 { + buf.reserve(size as usize); + retries -= 1; + } else { + return None; + } } } + + /// Iterates the adapters in the list. + fn iter(&self) -> impl Iterator { + iter_list( + self.buf.as_ptr().cast(), + |adapter: &IP_ADAPTER_ADDRESSES_LH| adapter.Next.cast_const(), + ) + } +} + +/// Iterates the unicast addresses of an adapter. +fn unicast_addrs( + adapter: &IP_ADAPTER_ADDRESSES_LH, +) -> impl Iterator { + iter_list( + adapter.FirstUnicastAddress.cast_const().cast(), + |unicast: &IP_ADAPTER_UNICAST_ADDRESS_LH| unicast.Next.cast_const(), + ) +} + +/// Iterates the gateway addresses of an adapter. +fn gateway_addrs( + adapter: &IP_ADAPTER_ADDRESSES_LH, +) -> impl Iterator { + iter_list(adapter.FirstGatewayAddress.cast_const(), |gateway| { + gateway.Next.cast_const() + }) +} + +/// The adapter name (its GUID string). +fn adapter_name(adapter: &IP_ADAPTER_ADDRESSES_LH) -> String { + // SAFETY: AdapterName is a NUL-terminated ANSI string owned by the + // adapter buffer. + let name = unsafe { CStr::from_ptr(adapter.AdapterName.0.cast()) }; + name.to_string_lossy().into_owned() +} + +/// The adapter's interface index; may be zero when IPv4 is disabled. +fn adapter_index(adapter: &IP_ADAPTER_ADDRESSES_LH) -> u32 { + // SAFETY: reading the IfIndex variant of the union is always valid; + // both variants are plain integers. + unsafe { adapter.Anonymous1.Anonymous.IfIndex } } /// Iterates a `Next`-linked list of structs inside the adapter buffer. +/// +/// The returned references borrow from the start pointer's referent, so +/// they cannot outlive the [`Adapters`] buffer they point into. fn iter_list<'a, T: 'a>( mut ptr: *const T, next: fn(&T) -> *const T, @@ -241,13 +277,15 @@ fn unicast_v6_flags(unicast: &IP_ADAPTER_UNICAST_ADDRESS_LH) -> Ipv6AddrFlags { /// Parses a `SOCKET_ADDRESS`, returning the address and, for IPv6, the /// scope ID. /// -/// # Safety -/// -/// `address.lpSockaddr` must be null or point to a sockaddr at least as -/// large as its family's `SOCKADDR_IN`/`SOCKADDR_IN6`, as the adapter -/// buffer guarantees. -unsafe fn socket_address_to_ip(address: &SOCKET_ADDRESS) -> Option<(IpAddr, u32)> { - // SAFETY: per the caller contract. +/// The unused adapter argument pins down that the sockaddr pointer +/// borrows from the adapter buffer, which guarantees it is null or +/// points to a sockaddr at least as large as its family's +/// `SOCKADDR_IN`/`SOCKADDR_IN6`. +fn socket_address_to_ip( + _owner: &IP_ADAPTER_ADDRESSES_LH, + address: &SOCKET_ADDRESS, +) -> Option<(IpAddr, u32)> { + // SAFETY: the sockaddr lives in the adapter buffer (see above). let sockaddr = unsafe { address.lpSockaddr.cast::().as_ref() }?; // SAFETY: si_family overlaps the family field of both variants. let family = unsafe { sockaddr.si_family }; diff --git a/netwatch/src/interfaces/enumerate/ifaddrs.rs b/netwatch/src/interfaces/enumerate/ifaddrs.rs index 3876f44e..07c9ba25 100644 --- a/netwatch/src/interfaces/enumerate/ifaddrs.rs +++ b/netwatch/src/interfaces/enumerate/ifaddrs.rs @@ -6,7 +6,9 @@ //! //! Every getifaddrs entry carries one address; entries are merged per //! interface name, with flags taken from the first entry of a name the -//! way netdev did it. +//! way netdev did it. The unsafe surface is confined to the [`IfAddrs`] +//! list wrapper, the [`Entry`] accessors, and the flag ioctl; the walk +//! itself is safe code. use std::{ ffi::CStr, @@ -28,55 +30,27 @@ const MAC_FAMILY: libc::c_int = libc::AF_LINK; /// Returns an empty list when the call fails; there is no error channel, /// matching the behavior callers have relied on so far. pub(super) fn interfaces() -> Vec { - #[cfg(target_os = "android")] - let Some((getifaddrs_fn, freeifaddrs_fn)) = compat::symbols() else { + let Some(list) = IfAddrs::load() else { return Vec::new(); }; - #[cfg(not(target_os = "android"))] - let (getifaddrs_fn, freeifaddrs_fn) = ( - libc::getifaddrs as unsafe extern "C" fn(*mut *mut libc::ifaddrs) -> libc::c_int, - libc::freeifaddrs as unsafe extern "C" fn(*mut libc::ifaddrs), - ); - - let mut list: *mut libc::ifaddrs = std::ptr::null_mut(); - // SAFETY: `list` is a valid out-pointer for getifaddrs. - if unsafe { getifaddrs_fn(&mut list) } != 0 { - return Vec::new(); - } let mut builders: Vec = Vec::new(); - let mut entry = list; - while !entry.is_null() { - // SAFETY: `entry` points at a live node of the getifaddrs list. - let ifa = unsafe { &*entry }; - entry = ifa.ifa_next; - - if ifa.ifa_name.is_null() { + for entry in list.iter() { + let Some(name) = entry.name() else { continue; - } - // SAFETY: `ifa_name` is a NUL-terminated string owned by the list. - let name = unsafe { CStr::from_ptr(ifa.ifa_name) } - .to_string_lossy() - .into_owned(); - + }; let position = match builders.iter().position(|builder| builder.name == name) { Some(position) => position, None => { - // SAFETY: `ifa_name` is valid for the duration of the call. - let index = unsafe { libc::if_nametoindex(ifa.ifa_name) }; - builders.push(IfaceBuilder::new(name, index, ifa.ifa_flags as u32)); + builders.push(IfaceBuilder::new(name, entry.index(), entry.flags())); builders.len() - 1 } }; let builder = &mut builders[position]; - // SAFETY: the entry's sockaddr pointers are valid or null. - let Some((family, sa)) = (unsafe { sockaddr_slice(ifa.ifa_addr) }) else { + let Some((family, sa)) = entry.addr() else { continue; }; - // SAFETY: as above. - let netmask = unsafe { sockaddr_slice(ifa.ifa_netmask) }; - if family == MAC_FAMILY { if let Some(mac) = parse_mac(sa) { builder.mac = Some(mac); @@ -86,7 +60,7 @@ pub(super) fn interfaces() -> Vec { continue; }; // A non-contiguous netmask fails here and drops the address. - let Ok(net) = Ipv4Net::with_netmask(ip, parse_v4_mask(netmask)) else { + let Ok(net) = Ipv4Net::with_netmask(ip, parse_v4_mask(entry.netmask())) else { continue; }; builder.push_v4(net); @@ -94,7 +68,7 @@ pub(super) fn interfaces() -> Vec { let Some((ip, raw_scope_id)) = parse_v6_addr(sa) else { continue; }; - let Ok(net) = Ipv6Net::with_netmask(ip, parse_v6_mask(netmask)) else { + let Ok(net) = Ipv6Net::with_netmask(ip, parse_v6_mask(entry.netmask())) else { continue; }; let scope_id = resolve_v6_scope_id(&ip, raw_scope_id, builder.index); @@ -103,12 +77,112 @@ pub(super) fn interfaces() -> Vec { } } - // SAFETY: `list` came from getifaddrs and is freed exactly once. - unsafe { freeifaddrs_fn(list) }; - builders.into_iter().map(IfaceBuilder::finish).collect() } +/// The list returned by `getifaddrs(3)`, freed on drop. +struct IfAddrs { + list: *mut libc::ifaddrs, + free: unsafe extern "C" fn(*mut libc::ifaddrs), +} + +impl IfAddrs { + /// Queries the list. + /// + /// Returns `None` when the call fails or, on android, when the + /// symbols are unavailable. + fn load() -> Option { + #[cfg(target_os = "android")] + let (getifaddrs, freeifaddrs) = compat::symbols()?; + #[cfg(not(target_os = "android"))] + let (getifaddrs, freeifaddrs) = ( + libc::getifaddrs as unsafe extern "C" fn(*mut *mut libc::ifaddrs) -> libc::c_int, + libc::freeifaddrs as unsafe extern "C" fn(*mut libc::ifaddrs), + ); + + let mut list: *mut libc::ifaddrs = std::ptr::null_mut(); + // SAFETY: `list` is a valid out-pointer for getifaddrs. + if unsafe { getifaddrs(&mut list) } != 0 { + return None; + } + Some(Self { + list, + free: freeifaddrs, + }) + } + + /// Iterates the entries of the list. + fn iter(&self) -> impl Iterator> { + let mut next = self.list.cast_const(); + std::iter::from_fn(move || { + // SAFETY: `next` is null or points at a live node of the + // list, which stays allocated for the borrow's lifetime. + let ifa = unsafe { next.as_ref() }?; + next = ifa.ifa_next; + Some(Entry { ifa }) + }) + } +} + +impl Drop for IfAddrs { + fn drop(&mut self) { + if !self.list.is_null() { + // SAFETY: `list` came from getifaddrs and is freed exactly + // once. + unsafe { (self.free)(self.list) }; + } + } +} + +/// One getifaddrs entry: an interface name paired with one address. +/// +/// The accessors encapsulate the pointer handling; the invariants they +/// rely on (NUL-terminated name, length-backed sockaddrs) are guaranteed +/// by getifaddrs for the lifetime of the [`IfAddrs`] list. +#[derive(Clone, Copy)] +struct Entry<'a> { + ifa: &'a libc::ifaddrs, +} + +impl<'a> Entry<'a> { + /// The interface name. + fn name(&self) -> Option { + if self.ifa.ifa_name.is_null() { + return None; + } + // SAFETY: ifa_name is a NUL-terminated string owned by the list. + let name = unsafe { CStr::from_ptr(self.ifa.ifa_name) }; + Some(name.to_string_lossy().into_owned()) + } + + /// The OS interface index; zero when the lookup fails. + fn index(&self) -> u32 { + if self.ifa.ifa_name.is_null() { + return 0; + } + // SAFETY: ifa_name is a valid NUL-terminated string, see name(). + unsafe { libc::if_nametoindex(self.ifa.ifa_name) } + } + + /// The interface flags (`IFF_*`). + fn flags(&self) -> u32 { + self.ifa.ifa_flags + } + + /// The entry's address family and sockaddr bytes. + fn addr(&self) -> Option<(libc::c_int, &'a [u8])> { + // SAFETY: ifa_addr is null or a sockaddr whose reported length is + // backed by its allocation, as getifaddrs guarantees. + unsafe { sockaddr_slice(self.ifa.ifa_addr) } + } + + /// The netmask family and sockaddr bytes. + fn netmask(&self) -> Option<(libc::c_int, &'a [u8])> { + // SAFETY: as for addr(). + unsafe { sockaddr_slice(self.ifa.ifa_netmask) } + } +} + /// Reads the address family and the valid bytes of a sockaddr. /// /// On BSD-derived systems the length comes from `sa_len` (clamped to the @@ -241,7 +315,7 @@ fn parse_mac(sa: &[u8]) -> Option<[u8; 6]> { /// these platforms. #[cfg(bsd)] fn ipv6_addr_flags(name: &str, addr: &Ipv6Addr) -> Ipv6AddrFlags { - use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + use std::os::fd::AsRawFd; // From in6_var.h (xnu and FreeBSD agree); the libc crate exposes // neither the ioctl number nor the flag bits. The request encodes a @@ -266,12 +340,9 @@ fn ipv6_addr_flags(name: &str, addr: &Ipv6Addr) -> Ipv6AddrFlags { let flags = Ipv6AddrFlags::default(); - let fd = unsafe { libc::socket(libc::AF_INET6, libc::SOCK_DGRAM, 0) }; - if fd < 0 { + let Ok(socket) = socket2::Socket::new(socket2::Domain::IPV6, socket2::Type::DGRAM, None) else { return flags; - } - // SAFETY: `fd` is a freshly created socket owned by no one else. - let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + }; let mut req = In6Ifreq { name: [0; libc::IFNAMSIZ], @@ -281,21 +352,20 @@ fn ipv6_addr_flags(name: &str, addr: &Ipv6Addr) -> Ipv6AddrFlags { let name_len = name.len().min(libc::IFNAMSIZ - 1); req.name[..name_len].copy_from_slice(&name[..name_len]); - // SAFETY: sockaddr_in6 is valid when zeroed. - let mut sin6: libc::sockaddr_in6 = unsafe { std::mem::zeroed() }; - sin6.sin6_len = std::mem::size_of::() as u8; - sin6.sin6_family = libc::AF_INET6 as libc::sa_family_t; - sin6.sin6_addr.s6_addr = addr.octets(); - // SAFETY: `data` is larger than sockaddr_in6. - unsafe { std::ptr::write_unaligned(req.data.as_mut_ptr().cast::(), sin6) }; + // The union starts with a sockaddr_in6 holding the queried address: + // the length and family bytes, then the address at offset 8. + req.data[0] = std::mem::size_of::() as u8; + req.data[1] = libc::AF_INET6 as u8; + req.data[8..24].copy_from_slice(&addr.octets()); - // SAFETY: `req` matches the size encoded in the ioctl request. - let res = unsafe { libc::ioctl(fd.as_raw_fd(), SIOCGIFAFLAG_IN6, &mut req) }; + // SAFETY: `req` matches the 288-byte struct size encoded in the + // ioctl request; the kernel reads and writes only within it. + let res = unsafe { libc::ioctl(socket.as_raw_fd(), SIOCGIFAFLAG_IN6, &mut req) }; if res != 0 { return flags; } - // SAFETY: `data` is larger than the flag word. - let raw = unsafe { std::ptr::read_unaligned(req.data.as_ptr().cast::()) }; + // On success the union holds the flag word instead of the address. + let raw = i32::from_ne_bytes(req.data[..4].try_into().expect("length checked")); Ipv6AddrFlags { deprecated: raw & IN6_IFF_DEPRECATED != 0, From 8ecdd6c704c7f32907b94ac7d7de051d7ab69ac3 Mon Sep 17 00:00:00 2001 From: Frando Date: Mon, 17 Aug 2026 23:07:08 +0200 Subject: [PATCH 09/10] docs: explain why the getifaddrs wrapper is hand-rolled, merge unsafe blocks Record in the module docs why nix is not used for the getifaddrs FFI (netmask truncation only handled on Apple targets, direct getifaddrs linkage breaking pre-API-24 Android, no flag ioctl). Merge the split unsafe blocks in sockaddr_slice, the windows sockaddr parser and the android dlsym lookup, each of which guards one invariant. Co-Authored-By: Claude Fable 5 --- netwatch/src/interfaces/enumerate/adapters.rs | 33 +++++---- netwatch/src/interfaces/enumerate/ifaddrs.rs | 74 +++++++++++-------- 2 files changed, 59 insertions(+), 48 deletions(-) diff --git a/netwatch/src/interfaces/enumerate/adapters.rs b/netwatch/src/interfaces/enumerate/adapters.rs index 4d0b512d..05faffe4 100644 --- a/netwatch/src/interfaces/enumerate/adapters.rs +++ b/netwatch/src/interfaces/enumerate/adapters.rs @@ -285,22 +285,23 @@ fn socket_address_to_ip( _owner: &IP_ADAPTER_ADDRESSES_LH, address: &SOCKET_ADDRESS, ) -> Option<(IpAddr, u32)> { - // SAFETY: the sockaddr lives in the adapter buffer (see above). - let sockaddr = unsafe { address.lpSockaddr.cast::().as_ref() }?; - // SAFETY: si_family overlaps the family field of both variants. - let family = unsafe { sockaddr.si_family }; - if family == AF_INET { - // SAFETY: family says this is a SOCKADDR_IN. - let octets = unsafe { sockaddr.Ipv4.sin_addr.S_un.S_addr }.to_ne_bytes(); - Some((IpAddr::V4(Ipv4Addr::from(octets)), 0)) - } else if family == AF_INET6 { - // SAFETY: family says this is a SOCKADDR_IN6. - let ip = IpAddr::from(unsafe { sockaddr.Ipv6.sin6_addr.u.Byte }); - // SAFETY: both union variants are a u32. - let scope_id = unsafe { sockaddr.Ipv6.Anonymous.sin6_scope_id }; - Some((ip, scope_id)) - } else { - None + // SAFETY: the sockaddr lives in the adapter buffer (see above); + // si_family overlaps the family field of both union variants and + // selects which one is initialized. + unsafe { + let sockaddr = address.lpSockaddr.cast::().as_ref()?; + let family = sockaddr.si_family; + if family == AF_INET { + let octets = sockaddr.Ipv4.sin_addr.S_un.S_addr.to_ne_bytes(); + Some((IpAddr::V4(Ipv4Addr::from(octets)), 0)) + } else if family == AF_INET6 { + let ip = IpAddr::from(sockaddr.Ipv6.sin6_addr.u.Byte); + // Both variants of the scope union are a u32. + let scope_id = sockaddr.Ipv6.Anonymous.sin6_scope_id; + Some((ip, scope_id)) + } else { + None + } } } diff --git a/netwatch/src/interfaces/enumerate/ifaddrs.rs b/netwatch/src/interfaces/enumerate/ifaddrs.rs index 07c9ba25..929983eb 100644 --- a/netwatch/src/interfaces/enumerate/ifaddrs.rs +++ b/netwatch/src/interfaces/enumerate/ifaddrs.rs @@ -9,6 +9,14 @@ //! way netdev did it. The unsafe surface is confined to the [`IfAddrs`] //! list wrapper, the [`Entry`] accessors, and the flag ioctl; the walk //! itself is safe code. +//! +//! We wrap the FFI ourselves instead of using nix, the one crate whose +//! getifaddrs wrapper exposes enough (flags, MAC, scope IDs): nix only +//! zero-pads the truncated netmask sockaddrs BSD kernels produce on +//! Apple targets (on the other BSDs the netmask would parse as /0), +//! links `getifaddrs` directly (bionic exports it only since API 24, so +//! binaries would stop loading on older Android), and does not cover +//! the flag ioctl anyway. use std::{ ffi::CStr, @@ -197,36 +205,35 @@ unsafe fn sockaddr_slice<'a>(sa: *const libc::sockaddr) -> Option<(libc::c_int, if sa.is_null() { return None; } - // SAFETY: `sa` points at a sockaddr per the caller contract. - let family = unsafe { (*sa).sa_family } as libc::c_int; - - #[cfg(bsd)] - let len = { - // SAFETY: as above. - let sa_len = unsafe { (*sa).sa_len } as usize; - if sa_len == 0 { - match family { - libc::AF_INET => std::mem::size_of::(), - libc::AF_INET6 => std::mem::size_of::(), - libc::AF_LINK => std::mem::size_of::(), - _ => return None, + // SAFETY: `sa` points at a sockaddr whose reported length is backed + // by its allocation, per the caller contract. + unsafe { + let family = (*sa).sa_family as libc::c_int; + + #[cfg(bsd)] + let len = { + let sa_len = (*sa).sa_len as usize; + if sa_len == 0 { + match family { + libc::AF_INET => std::mem::size_of::(), + libc::AF_INET6 => std::mem::size_of::(), + libc::AF_LINK => std::mem::size_of::(), + _ => return None, + } + } else { + sa_len.min(std::mem::size_of::()) } - } else { - sa_len.min(std::mem::size_of::()) - } - }; - #[cfg(any(target_os = "linux", target_os = "android"))] - let len = match family { - libc::AF_INET => std::mem::size_of::(), - libc::AF_INET6 => std::mem::size_of::(), - libc::AF_PACKET => std::mem::size_of::(), - _ => return None, - }; + }; + #[cfg(any(target_os = "linux", target_os = "android"))] + let len = match family { + libc::AF_INET => std::mem::size_of::(), + libc::AF_INET6 => std::mem::size_of::(), + libc::AF_PACKET => std::mem::size_of::(), + _ => return None, + }; - // SAFETY: `len` bytes are backed per the caller contract. - Some((family, unsafe { - std::slice::from_raw_parts(sa.cast::(), len) - })) + Some((family, std::slice::from_raw_parts(sa.cast::(), len))) + } } /// Parses the address of a full-length `sockaddr_in`. @@ -399,10 +406,13 @@ mod compat { pub(super) fn symbols() -> Option<(GetIfAddrsFn, FreeIfAddrsFn)> { static SYMBOLS: OnceLock> = OnceLock::new(); let (getifaddrs, freeifaddrs) = (*SYMBOLS.get_or_init(|| { - // SAFETY: dlsym with a valid NUL-terminated symbol name. - let getifaddrs = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"getifaddrs".as_ptr()) }; - // SAFETY: as above. - let freeifaddrs = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"freeifaddrs".as_ptr()) }; + // SAFETY: dlsym with valid NUL-terminated symbol names. + let (getifaddrs, freeifaddrs) = unsafe { + ( + libc::dlsym(libc::RTLD_DEFAULT, c"getifaddrs".as_ptr()), + libc::dlsym(libc::RTLD_DEFAULT, c"freeifaddrs".as_ptr()), + ) + }; if getifaddrs.is_null() || freeifaddrs.is_null() { None } else { From f047ec9e0c9633a520701594be37bd27577e8f2f Mon Sep 17 00:00:00 2001 From: Frando Date: Tue, 18 Aug 2026 16:04:23 +0200 Subject: [PATCH 10/10] chore: fix lints --- Cargo.lock | 4 ++-- deny.toml | 2 -- netwatch-netlink/Cargo.toml | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 24078867..b252bae1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -442,9 +442,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", diff --git a/deny.toml b/deny.toml index 3ecdc88e..4b7f0a32 100644 --- a/deny.toml +++ b/deny.toml @@ -21,8 +21,6 @@ license-files = [{ path = "LICENSE", hash = 0xbd0eed23 }] [advisories] ignore = [ "RUSTSEC-2024-0436", # paste unmaintained - "RUSTSEC-2026-0194", # netwatch -> netdev -> plist -> quick-xml: remove once https://github.com/ebarnard/rust-plist/pull/191 is released - "RUSTSEC-2026-0195", # netwatch -> netdev -> plist -> quick-xml: remove once https://github.com/ebarnard/rust-plist/pull/191 is released ] [sources] diff --git a/netwatch-netlink/Cargo.toml b/netwatch-netlink/Cargo.toml index a9e1787a..2b3f9e8a 100644 --- a/netwatch-netlink/Cargo.toml +++ b/netwatch-netlink/Cargo.toml @@ -21,11 +21,11 @@ workspace = true libc = "0.2.139" n0-error = "1.0.0" socket2 = "0.6" -tokio = { version = "1", features = ["net", "time"] } +tokio = { version = "1.41", features = ["net", "time"] } tracing = "0.1" [target.'cfg(any(target_os = "linux", target_os = "android"))'.dev-dependencies] -tokio = { version = "1", features = ["macros", "rt", "time"] } +tokio = { version = "1.41", features = ["macros", "rt", "time"] } [package.metadata.docs.rs] targets = ["x86_64-unknown-linux-gnu", "aarch64-linux-android"]