diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e0ee0b8..016e62d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -147,6 +147,39 @@ 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')" + timeout-minutes: 30 + runs-on: ubuntu-latest + env: + RUSTC_WRAPPER: "sccache" + SCCACHE_GHA_ENABLED: "on" + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@nightly + with: + components: rust-src + - uses: mozilla-actions/sccache-action@v0.0.9 + - name: Check workspace for ESP32-C3 + run: cargo check -Z build-std=std,panic_abort --target riscv32imc-esp-espidf --workspace --no-default-features + wasm_test: name: Build & test wasm32 for browsers runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index c6f37a8..a2a6107 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -751,13 +751,14 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iroh-metrics" -version = "0.38.2" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c946095f060e6e59b9ff30cc26c75cdb758e7fb0cde8312c89e2144654989fcb" +checksum = "761b45ba046134b11eb3e432fa501616b45c4bf3a30c21717578bc07aa6461dd" dependencies = [ "iroh-metrics-derive", "itoa", "n0-error", + "portable-atomic", "postcard", "ryu", "serde", @@ -1017,6 +1018,7 @@ dependencies = [ "bytes", "cfg_aliases", "derive_more", + "ipnet", "js-sys", "libc", "n0-error", @@ -1268,6 +1270,15 @@ dependencies = [ "time", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + [[package]] name = "portmapper" version = "0.14.0" diff --git a/Cargo.toml b/Cargo.toml index 208c81e..f0b33e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ missing_debug_implementations = "warn" # require a feature enabled when using `--cfg docsrs` which we can not # do. To enable for a crate set `#![cfg_attr(iroh_docsrs, # feature(doc_cfg))]` in the crate. -unexpected_cfgs = { level = "warn", check-cfg = ["cfg(iroh_docsrs)"] } +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(iroh_docsrs)", "cfg(has_netdev)"] } [workspace.lints.clippy] unused-async = "warn" diff --git a/netwatch/Cargo.toml b/netwatch/Cargo.toml index 43b2f99..1414c12 100644 --- a/netwatch/Cargo.toml +++ b/netwatch/Cargo.toml @@ -34,19 +34,18 @@ tracing = "0.1" # non-browser dependencies [target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] +ipnet = "2" noq-udp = "0.9" libc = "0.2.139" -netdev = "0.40.1" 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.40.1" tokio = { version = "1", features = [ - "io-util", - "macros", - "sync", - "rt", - "net", "fs", "io-std", - "time", ] } [target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] diff --git a/netwatch/build.rs b/netwatch/build.rs index 7aae568..e4f9a69 100644 --- a/netwatch/build.rs +++ b/netwatch/build.rs @@ -5,5 +5,7 @@ fn main() { cfg_aliases! { // Convenience aliases wasm_browser: { all(target_family = "wasm", target_os = "unknown") }, + // Limited POSIX platforms (not wasm) + posix_minimal: { target_os = "espidf" }, } } diff --git a/netwatch/src/interfaces.rs b/netwatch/src/interfaces.rs index 2077946..85d2f3b 100644 --- a/netwatch/src/interfaces.rs +++ b/netwatch/src/interfaces.rs @@ -17,7 +17,7 @@ mod linux; #[cfg(target_os = "windows")] mod windows; -pub(crate) use netdev::ipnet::{Ipv4Net, Ipv6Net}; +pub(crate) use ipnet::{Ipv4Net, Ipv6Net}; #[cfg(any( target_os = "freebsd", diff --git a/netwatch/src/interfaces/posix_minimal.rs b/netwatch/src/interfaces/posix_minimal.rs new file mode 100644 index 0000000..aff5727 --- /dev/null +++ b/netwatch/src/interfaces/posix_minimal.rs @@ -0,0 +1,370 @@ +//! 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, + ffi::CStr, + fmt, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, +}; + +pub(crate) use ipnet::{Ipv4Net, Ipv6Net}; +use n0_future::time::Instant; + +use crate::ip::LocalAddresses; + +// 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 { + name: String, + flags: u32, + ipv4: Vec, + ipv6: Vec, +} + +impl fmt::Display for Interface { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ipv4={:?} ipv6={:?}", self.name, self.ipv4, self.ipv6) + } +} + +impl Interface { + /// Is this interface up? + pub fn is_up(&self) -> bool { + self.flags & IFF_UP != 0 + } + + /// The name of the interface. + pub fn name(&self) -> &str { + &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 + '_ { + self.ipv4 + .iter() + .cloned() + .map(IpNet::V4) + .chain(self.ipv6.iter().cloned().map(IpNet::V6)) + } +} + +/// Structure of an IP network, either IPv4 or IPv6. +#[derive(Clone, Debug)] +pub enum IpNet { + /// Structure of IPv4 Network. + V4(Ipv4Net), + /// Structure of IPv6 Network. + V6(Ipv6Net), +} + +impl PartialEq for IpNet { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (IpNet::V4(a), IpNet::V4(b)) => { + a.addr() == b.addr() + && a.prefix_len() == b.prefix_len() + && a.netmask() == b.netmask() + } + (IpNet::V6(a), IpNet::V6(b)) => { + a.addr() == b.addr() + && a.prefix_len() == b.prefix_len() + && a.netmask() == b.netmask() + } + _ => false, + } + } +} +impl Eq for IpNet {} + +impl IpNet { + /// The IP address of this structure. + pub fn addr(&self) -> IpAddr { + match self { + IpNet::V4(a) => IpAddr::V4(a.addr()), + IpNet::V6(a) => IpAddr::V6(a.addr()), + } + } +} + +/// 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. + pub gateway: IpAddr, + /// Our local Ip if known. + pub my_ip: Option, +} + +impl HomeRouter { + /// Returns `None` — no gateway discovery available on this platform. + pub fn new() -> Option { + None + } +} + +/// 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 { + /// Maps from an interface name to interface. + pub interfaces: HashMap, + /// List of machine's local IP addresses. + pub local_addresses: LocalAddresses, + /// Whether this machine has IPv6 connectivity. + pub have_v6: bool, + /// Whether the machine has IPv4 connectivity. + pub have_v4: bool, + /// Whether the current network interface is considered "expensive". + pub is_expensive: bool, + /// The interface name for the machine's default route. + pub default_route_interface: Option, + /// Monotonic timestamp, when an unsuspend was detected. + pub last_unsuspend: Option, +} + +impl fmt::Display for State { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + 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 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, + local_addresses, + have_v4, + have_v6, + is_expensive: false, + default_route_interface: None, + last_unsuspend: None, + } + } + + /// Creates a fake interface state for usage in tests. + pub fn fake() -> Self { + Self { + interfaces: HashMap::new(), + local_addresses: LocalAddresses::default(), + have_v6: false, + have_v4: true, + is_expensive: false, + default_route_interface: None, + last_unsuspend: None, + } + } + + /// Is this a major change compared to the `old` one? + pub fn is_major_change(&self, old: &State) -> bool { + self != old + } +} diff --git a/netwatch/src/ip_posix_minimal.rs b/netwatch/src/ip_posix_minimal.rs new file mode 100644 index 0000000..398d71e --- /dev/null +++ b/netwatch/src/ip_posix_minimal.rs @@ -0,0 +1,41 @@ +//! IP address related utilities — minimal POSIX implementation. + +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, + /// Regular addresses. + 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/lib.rs b/netwatch/src/lib.rs index 5cd2c9f..cb93968 100644 --- a/netwatch/src/lib.rs +++ b/netwatch/src/lib.rs @@ -1,7 +1,9 @@ //! Networking related utilities #[cfg_attr(wasm_browser, path = "interfaces/wasm_browser.rs")] +#[cfg_attr(posix_minimal, path = "interfaces/posix_minimal.rs")] pub mod interfaces; +#[cfg_attr(posix_minimal, path = "ip_posix_minimal.rs")] pub mod ip; mod ip_family; pub mod netmon; diff --git a/netwatch/src/netmon.rs b/netwatch/src/netmon.rs index 22cafaa..426fb56 100644 --- a/netwatch/src/netmon.rs +++ b/netwatch/src/netmon.rs @@ -6,24 +6,29 @@ 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(wasm_browser))] +#[cfg(not(any(posix_minimal, wasm_browser)))] pub(crate) use self::actor::is_interesting_interface; use self::actor::{Actor, ActorMessage}; pub use crate::interfaces::State; diff --git a/netwatch/src/netmon/actor.rs b/netwatch/src/netmon/actor.rs index 3ce5bf5..ad33716 100644 --- a/netwatch/src/netmon/actor.rs +++ b/netwatch/src/netmon/actor.rs @@ -2,26 +2,31 @@ use n0_future::time::{self, Duration, Instant}; use n0_watcher::Watchable; pub(super) use os::Error; use os::RouteMonitor; -#[cfg(not(wasm_browser))] +#[cfg(not(any(posix_minimal, wasm_browser)))] 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 new file mode 100644 index 0000000..a84fb52 --- /dev/null +++ b/netwatch/src/netmon/posix_minimal.rs @@ -0,0 +1,38 @@ +//! 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 { + _handle: tokio::task::JoinHandle<()>, +} + +impl RouteMonitor { + pub(super) fn new(sender: mpsc::Sender) -> Result { + 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 }) + } +}