Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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')"
Expand Down
254 changes: 238 additions & 16 deletions netwatch/src/interfaces/posix_minimal.rs
Original file line number Diff line number Diff line change
@@ -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<IpAddr> {
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<IpAddr>,
netmask: Option<IpAddr>,
}

/// Call `getifaddrs` and collect all entries.
fn enumerate_ifaddrs() -> Vec<IfAddrEntry> {
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<Ipv4Net>,
ipv6: Vec<Ipv6Net>,
}

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<Item = IpNet> + '_ {
std::iter::empty()
self.ipv4
.iter()
.cloned()
.map(IpNet::V4)
.chain(self.ipv6.iter().cloned().map(IpNet::V6))
}
}

Expand Down Expand Up @@ -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.
Expand All @@ -89,6 +215,48 @@ impl HomeRouter {
}
}

/// Collect `getifaddrs` entries into a map of `Interface` structs grouped by name.
fn collect_interfaces() -> HashMap<String, Interface> {
let entries = enumerate_ifaddrs();
let mut map: HashMap<String, Interface> = 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 {
Expand All @@ -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,
Expand Down
39 changes: 35 additions & 4 deletions netwatch/src/ip_posix_minimal.rs
Original file line number Diff line number Diff line change
@@ -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<std::net::IpAddr>,
pub loopback: Vec<IpAddr>,
/// Regular addresses.
pub regular: Vec<std::net::IpAddr>,
pub regular: Vec<IpAddr>,
}

impl LocalAddresses {
/// Build local addresses from already-enumerated interfaces.
pub(crate) fn from_interfaces<'a>(ifaces: impl Iterator<Item = &'a Interface>) -> 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 }
}
}
Loading
Loading