diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bcefd2c..016e62d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -147,6 +147,22 @@ jobs: env: RUST_LOG: ${{ runner.debug && 'TRACE' || 'DEBUG' }} + test_posix_minimal: + name: Test posix_minimal (netwatch) + if: "github.event_name != 'pull_request' || ! contains(github.event.pull_request.labels.*.name, 'flaky-test')" + timeout-minutes: 30 + runs-on: ubuntu-latest + env: + RUSTC_WRAPPER: "sccache" + SCCACHE_GHA_ENABLED: "on" + RUSTFLAGS: "-Dwarnings --cfg posix_minimal" + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: mozilla-actions/sccache-action@v0.0.9 + - name: Build and test netwatch (posix_minimal) + run: cargo test --locked -p netwatch + esp32_check: name: ESP32-C3 build check if: "github.event_name != 'pull_request' || ! contains(github.event.pull_request.labels.*.name, 'flaky-test')" diff --git a/netwatch/src/interfaces/posix_minimal.rs b/netwatch/src/interfaces/posix_minimal.rs index 7b3e716..aff5727 100644 --- a/netwatch/src/interfaces/posix_minimal.rs +++ b/netwatch/src/interfaces/posix_minimal.rs @@ -1,37 +1,161 @@ -//! Fallback interfaces implementation for platforms without `netdev`. -//! Provides stub types — no network interface enumeration available. +//! Minimal POSIX interfaces implementation using `getifaddrs`. +//! +//! Used on platforms (like ESP-IDF) where `netdev` is not available but +//! standard POSIX networking APIs are supported. -use std::{collections::HashMap, fmt, net::IpAddr}; +use std::{ + collections::HashMap, + ffi::CStr, + fmt, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, +}; pub(crate) use ipnet::{Ipv4Net, Ipv6Net}; use n0_future::time::Instant; use crate::ip::LocalAddresses; -/// Represents a network interface (stub). +// POSIX interface flags — standard values across all POSIX systems. +const IFF_UP: u32 = 0x1; +const IFF_LOOPBACK: u32 = 0x8; + +/// FFI declarations for `getifaddrs`/`freeifaddrs`. +/// +/// We declare these manually because the `libc` crate may not expose them +/// for all targets (e.g. espidf). ESP-IDF's lwIP provides these since IDF v5.0. +mod ffi { + #[repr(C)] + pub(super) struct ifaddrs { + pub ifa_next: *mut ifaddrs, + pub ifa_name: *mut libc::c_char, + pub ifa_flags: libc::c_uint, + pub ifa_addr: *mut libc::sockaddr, + pub ifa_netmask: *mut libc::sockaddr, + pub ifa_ifu: *mut libc::sockaddr, + pub ifa_data: *mut libc::c_void, + } + + unsafe extern "C" { + pub(super) fn getifaddrs(ifap: *mut *mut ifaddrs) -> libc::c_int; + pub(super) fn freeifaddrs(ifa: *mut ifaddrs); + } +} + +/// Extract an IP address from a raw `sockaddr` pointer. +/// +/// # Safety +/// The pointer must be null or point to a valid `sockaddr_in` or `sockaddr_in6`. +unsafe fn sockaddr_to_ip(sa: *const libc::sockaddr) -> Option { + if sa.is_null() { + return None; + } + // Safety: caller guarantees sa is valid + unsafe { + match (*sa).sa_family as i32 { + libc::AF_INET => { + let sa_in = sa as *const libc::sockaddr_in; + let ip = Ipv4Addr::from(u32::from_be((*sa_in).sin_addr.s_addr)); + Some(IpAddr::V4(ip)) + } + libc::AF_INET6 => { + let sa_in6 = sa as *const libc::sockaddr_in6; + let ip = Ipv6Addr::from((*sa_in6).sin6_addr.s6_addr); + Some(IpAddr::V6(ip)) + } + _ => None, + } + } +} + +/// Convert a netmask IP to a prefix length by counting leading ones. +fn prefix_len(mask: IpAddr) -> u8 { + match mask { + IpAddr::V4(m) => u32::from_be_bytes(m.octets()).leading_ones() as u8, + IpAddr::V6(m) => u128::from_be_bytes(m.octets()).leading_ones() as u8, + } +} + +/// A single address entry from `getifaddrs`. +struct IfAddrEntry { + name: String, + flags: u32, + addr: Option, + netmask: Option, +} + +/// Call `getifaddrs` and collect all entries. +fn enumerate_ifaddrs() -> Vec { + let mut result = Vec::new(); + let mut ifap: *mut ffi::ifaddrs = std::ptr::null_mut(); + + // Safety: getifaddrs is a standard POSIX function. + unsafe { + if ffi::getifaddrs(&mut ifap) != 0 { + return result; + } + + let mut cursor = ifap; + while !cursor.is_null() { + let ifa = &*cursor; + if !ifa.ifa_name.is_null() { + let name = CStr::from_ptr(ifa.ifa_name).to_string_lossy().into_owned(); + let flags = ifa.ifa_flags; + let addr = sockaddr_to_ip(ifa.ifa_addr); + let netmask = sockaddr_to_ip(ifa.ifa_netmask); + result.push(IfAddrEntry { + name, + flags, + addr, + netmask, + }); + } + cursor = ifa.ifa_next; + } + + ffi::freeifaddrs(ifap); + } + + result +} + +/// Represents a network interface. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct Interface; +pub struct Interface { + name: String, + flags: u32, + ipv4: Vec, + ipv6: Vec, +} impl fmt::Display for Interface { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "unknown") + write!(f, "{} ipv4={:?} ipv6={:?}", self.name, self.ipv4, self.ipv6) } } impl Interface { /// Is this interface up? pub fn is_up(&self) -> bool { - false + self.flags & IFF_UP != 0 } /// The name of the interface. pub fn name(&self) -> &str { - "unknown" + &self.name + } + + /// Is this a loopback interface? + pub(crate) fn is_loopback(&self) -> bool { + self.flags & IFF_LOOPBACK != 0 } /// A list of all ip addresses of this interface. pub fn addrs(&self) -> impl Iterator + '_ { - std::iter::empty() + self.ipv4 + .iter() + .cloned() + .map(IpNet::V4) + .chain(self.ipv6.iter().cloned().map(IpNet::V6)) } } @@ -73,7 +197,9 @@ impl IpNet { } } -/// The router/gateway of the local network (stub — always returns `None`). +/// The router/gateway of the local network. +/// +/// Gateway discovery is not available on this platform. #[derive(Debug, Clone)] pub struct HomeRouter { /// Ip of the router. @@ -89,6 +215,48 @@ impl HomeRouter { } } +/// Collect `getifaddrs` entries into a map of `Interface` structs grouped by name. +fn collect_interfaces() -> HashMap { + let entries = enumerate_ifaddrs(); + let mut map: HashMap = HashMap::new(); + + for entry in entries { + let iface = map.entry(entry.name.clone()).or_insert_with(|| Interface { + name: entry.name, + flags: entry.flags, + ipv4: Vec::new(), + ipv6: Vec::new(), + }); + + // Merge flags (take the union). + iface.flags |= entry.flags; + + if let Some(addr) = entry.addr { + let pfx = entry.netmask.map(prefix_len); + match addr { + IpAddr::V4(v4) => { + if let Ok(net) = Ipv4Net::new(v4, pfx.unwrap_or(32)) { + iface.ipv4.push(net); + } + } + IpAddr::V6(v6) => { + if let Ok(net) = Ipv6Net::new(v6, pfx.unwrap_or(128)) { + iface.ipv6.push(net); + } + } + } + } + } + + // Sort addresses for stable comparison. + for iface in map.values_mut() { + iface.ipv4.sort(); + iface.ipv6.sort(); + } + + map +} + /// Intended to store the state of the machine's network interfaces. #[derive(Debug, PartialEq, Eq, Clone)] pub struct State { @@ -110,18 +278,72 @@ pub struct State { impl fmt::Display for State { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "fallback(no interfaces)") + for iface in self.interfaces.values() { + write!(f, "{iface}")?; + if let Some(ref default_if) = self.default_route_interface + && iface.name() == default_if + { + write!(f, " (default)")?; + } + if f.alternate() { + writeln!(f)?; + } else { + write!(f, "; ")?; + } + } + Ok(()) + } +} + +/// Reports whether ip is a usable IPv4 address. +fn is_usable_v4(ip: &IpAddr) -> bool { + ip.is_ipv4() && !ip.is_loopback() +} + +/// Reports whether ip is a usable IPv6 address (global or ULA). +fn is_usable_v6(ip: &IpAddr) -> bool { + match ip { + IpAddr::V6(ip) => { + let segment1 = ip.segments()[0]; + // Global unicast: 2000::/3 + if (segment1 & 0xe000) == 0x2000 { + return true; + } + // Unique local: fc00::/7 + ip.octets()[0] & 0xfe == 0xfc + } + IpAddr::V4(_) => false, } } impl State { - /// Returns a default empty state (no interface enumeration on this platform). + /// Returns the state of all the current machine's network interfaces. pub async fn new() -> Self { + let interfaces = collect_interfaces(); + let local_addresses = LocalAddresses::from_interfaces(interfaces.values()); + + let mut have_v4 = false; + let mut have_v6 = false; + + for iface in interfaces.values() { + if !iface.is_up() { + continue; + } + for pfx in iface.addrs() { + let addr = pfx.addr(); + if addr.is_loopback() { + continue; + } + have_v4 |= is_usable_v4(&addr); + have_v6 |= is_usable_v6(&addr); + } + } + State { - interfaces: HashMap::new(), - local_addresses: LocalAddresses::default(), - have_v6: false, - have_v4: true, + interfaces, + local_addresses, + have_v4, + have_v6, is_expensive: false, default_route_interface: None, last_unsuspend: None, diff --git a/netwatch/src/ip_posix_minimal.rs b/netwatch/src/ip_posix_minimal.rs index 38c5984..398d71e 100644 --- a/netwatch/src/ip_posix_minimal.rs +++ b/netwatch/src/ip_posix_minimal.rs @@ -1,10 +1,41 @@ -//! IP address related utilities — fallback for platforms without `netdev`. +//! IP address related utilities — minimal POSIX implementation. -/// List of machine's IP addresses (stub). +use std::net::IpAddr; + +use crate::interfaces::Interface; + +/// List of machine's IP addresses. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct LocalAddresses { /// Loopback addresses. - pub loopback: Vec, + pub loopback: Vec, /// Regular addresses. - pub regular: Vec, + pub regular: Vec, +} + +impl LocalAddresses { + /// Build local addresses from already-enumerated interfaces. + pub(crate) fn from_interfaces<'a>(ifaces: impl Iterator) -> Self { + let mut loopback = Vec::new(); + let mut regular = Vec::new(); + + for iface in ifaces { + if !iface.is_up() { + continue; + } + for pfx in iface.addrs() { + let ip = pfx.addr(); + if ip.is_loopback() || iface.is_loopback() { + loopback.push(ip); + } else { + regular.push(ip); + } + } + } + + loopback.sort(); + regular.sort(); + + LocalAddresses { loopback, regular } + } } diff --git a/netwatch/src/netmon.rs b/netwatch/src/netmon.rs index c7c9ffc..426fb56 100644 --- a/netwatch/src/netmon.rs +++ b/netwatch/src/netmon.rs @@ -6,23 +6,26 @@ use n0_watcher::Watchable; use tokio::sync::{mpsc, oneshot}; mod actor; -#[cfg(target_os = "android")] +#[cfg(all(target_os = "android", not(posix_minimal)))] mod android; -#[cfg(any( - target_os = "freebsd", - target_os = "openbsd", - target_os = "netbsd", - target_os = "macos", - target_os = "ios" +#[cfg(all( + any( + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "macos", + target_os = "ios" + ), + not(posix_minimal) ))] mod bsd; -#[cfg(target_os = "linux")] +#[cfg(all(target_os = "linux", not(posix_minimal)))] mod linux; #[cfg(posix_minimal)] mod posix_minimal; #[cfg(wasm_browser)] mod wasm_browser; -#[cfg(target_os = "windows")] +#[cfg(all(target_os = "windows", not(posix_minimal)))] mod windows; #[cfg(not(any(posix_minimal, wasm_browser)))] diff --git a/netwatch/src/netmon/actor.rs b/netwatch/src/netmon/actor.rs index a2783df..ad33716 100644 --- a/netwatch/src/netmon/actor.rs +++ b/netwatch/src/netmon/actor.rs @@ -7,23 +7,26 @@ pub(crate) use os::is_interesting_interface; use tokio::sync::mpsc; use tracing::{debug, trace}; -#[cfg(target_os = "android")] +#[cfg(all(target_os = "android", not(posix_minimal)))] use super::android as os; -#[cfg(any( - target_os = "freebsd", - target_os = "openbsd", - target_os = "netbsd", - target_os = "macos", - target_os = "ios" +#[cfg(all( + any( + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "macos", + target_os = "ios" + ), + not(posix_minimal) ))] use super::bsd as os; -#[cfg(target_os = "linux")] +#[cfg(all(target_os = "linux", not(posix_minimal)))] use super::linux as os; #[cfg(posix_minimal)] use super::posix_minimal as os; #[cfg(wasm_browser)] use super::wasm_browser as os; -#[cfg(target_os = "windows")] +#[cfg(all(target_os = "windows", not(posix_minimal)))] use super::windows as os; use crate::interfaces::State; diff --git a/netwatch/src/netmon/posix_minimal.rs b/netwatch/src/netmon/posix_minimal.rs index 5ede660..a84fb52 100644 --- a/netwatch/src/netmon/posix_minimal.rs +++ b/netwatch/src/netmon/posix_minimal.rs @@ -1,21 +1,38 @@ -//! Fallback netmon implementation for platforms without OS-specific route monitoring. -//! Does nothing — no route monitoring available. +//! Polling-based route monitor for platforms without OS-specific change notifications. +//! +//! Since ESP-IDF doesn't have netlink or routing sockets, we poll for changes +//! on a fixed interval and let the actor debounce and diff. use n0_error::stack_error; +use n0_future::time::Duration; use tokio::sync::mpsc; use super::actor::NetworkMessage; +/// Poll interval for checking network changes. +const POLL_INTERVAL: Duration = Duration::from_secs(30); + #[stack_error(derive, add_meta)] pub struct Error; #[derive(Debug)] pub(super) struct RouteMonitor { - _sender: mpsc::Sender, + _handle: tokio::task::JoinHandle<()>, } impl RouteMonitor { pub(super) fn new(sender: mpsc::Sender) -> Result { - Ok(RouteMonitor { _sender: sender }) + let handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(POLL_INTERVAL); + // Skip the immediate first tick. + interval.tick().await; + loop { + interval.tick().await; + if sender.send(NetworkMessage::Change).await.is_err() { + break; + } + } + }); + Ok(RouteMonitor { _handle: handle }) } }