getifs 0.6.0

Cross-platform enumeration of network interfaces and their MTU, gateway, multicast, and local/private/public IP addresses.
Documentation
#![allow(dead_code)]

use std::{net::IpAddr, process::Command, thread, time::Duration};

use crate::interfaces;

#[cfg(bsd_like)]
mod bsd;

#[cfg(linux_like)]
mod linux;

struct TestInterface {
  name: String,
  local: IpAddr,
  remote: IpAddr,
  setup_cmds: Vec<Command>,
  teardown_cmds: Vec<Command>,
}

impl TestInterface {
  fn new(local: IpAddr, remote: IpAddr) -> Self {
    Self {
      name: String::new(),
      local,
      remote,
      setup_cmds: Vec::new(),
      teardown_cmds: Vec::new(),
    }
  }

  fn setup(&mut self) -> std::io::Result<()> {
    // Run each command exactly once. The previous version invoked
    // both `cmd.status()` and `cmd.output()` per command, which
    // re-spawned the same process twice — for setup commands like
    // `ip link add` or `ifconfig <name> create` the first invocation
    // succeeds (creating the device) and the second fails with
    // "RTNETLINK answers: File exists" / "Device or resource busy",
    // surfacing as a generic error string that the call sites'
    // skip-on-environmental-failure branch could match. That bypassed
    // teardown and leaked devices in privileged CI.
    for cmd in &mut self.setup_cmds {
      run_once(cmd)?;
    }
    Ok(())
  }

  fn teardown(&mut self) -> std::io::Result<()> {
    for cmd in &mut self.teardown_cmds {
      run_once(cmd)?;
    }
    Ok(())
  }

  /// Best-effort cleanup. Used in error paths after a partial setup —
  /// we want to take the device down regardless of whether some
  /// teardown step's precondition is missing.
  fn try_teardown(&mut self) {
    for cmd in &mut self.teardown_cmds {
      let _ = cmd.output();
    }
  }
}

/// Run a `Command` exactly once. On non-success, the returned error
/// includes the command's stderr so call sites can pattern-match on
/// the actual diagnostic ("No such device", BSD ifconfig SIOCIFCREATE
/// failures, etc.) rather than a generic "command failed".
fn run_once(cmd: &mut Command) -> std::io::Result<()> {
  let output = cmd.output()?;
  if output.status.success() {
    return Ok(());
  }
  let args: Vec<_> = cmd.get_args().collect();
  Err(std::io::Error::other(format!(
    "{:?} failed (status={:?}): {}",
    args,
    output.status.code(),
    String::from_utf8_lossy(&output.stderr).trim()
  )))
}

/// Treat as "host doesn't support this" rather than a library bug.
/// Pattern-matches the actual stderr text from `ip(8)` / `ifconfig(8)`
/// for the missing-kernel-module / missing-device errors we hit on
/// container CI runners. Anything else is a real failure and panics.
fn is_environmental_skip(msg: &str) -> bool {
  msg.contains("No such device")
    || msg.contains("Cannot find device")
    || msg.contains("SIOCIFCREATE")
    || msg.contains("SIOCSIFFLAGS")
    || msg.contains("not supported")
    || msg.contains("Operation not supported")
    || msg.contains("module")
}

#[test]
// NetBSD's CI VMs emit a non-canonical `RTAX_NETMASK` for the kind of
// TUN/P2P interface this test creates (the mask slot carries
// peer-address bytes instead of a contiguous prefix mask), which made
// the test flaky there even after `interface_addr_table_into` was
// taught to skip such addresses gracefully — the test then has no
// addresses left to assert against. Skip on NetBSD rather than dilute
// the assertion. Apple is excluded because `tuntaposx` isn't shipped
// with macOS by default.
#[cfg(all(not(apple), not(target_os = "netbsd"), unix))]
fn point_to_point_interface() {
  #[cfg(bsd_like)]
  let uid = unsafe { libc::getuid() };
  #[cfg(linux_like)]
  let uid = rustix::process::getuid().as_raw();
  if uid != 0 {
    return;
  }

  // Using IPv4 link-local addresses
  let local: IpAddr = "169.254.0.1".parse().unwrap();
  let remote: IpAddr = "169.254.0.254".parse().unwrap();

  for i in 0..3 {
    let mut ti = TestInterface::new(local, remote);

    if let Err(e) = ti.set_point_to_point(5963 + i) {
      panic!("test requires external command: {}", e);
    }

    match ti.setup() {
      Ok(_) => {
        std::thread::sleep(Duration::from_millis(3));
      }
      Err(e) => {
        // Always attempt to undo any partial setup before deciding
        // whether to skip or fail — leaking interfaces in privileged
        // CI causes the next test in the loop to collide on the same
        // name.
        ti.try_teardown();
        let err_msg = e.to_string();
        // Reasons interface creation can fail in CI VMs we treat as
        // environmental:
        //   - Linux containers don't ship a `gre0` device → "No such
        //     device" / "Cannot find device".
        //   - FreeBSD/NetBSD/OpenBSD CI VMs typically don't load the
        //     `if_gif` / `if_vlan` kernel modules → ifconfig surfaces
        //     `SIOCIFCREATE` / "Operation not supported" / similar.
        // Anything else is a real bug and should panic.
        if is_environmental_skip(&err_msg) {
          println!(
            "skipping test; interface creation failed (likely missing kernel module): {err_msg}"
          );
          return;
        }
        panic!("{}", e);
      }
    }

    match interfaces() {
      Ok(interfaces) => {
        for ifi in interfaces {
          if ti.name != ifi.name {
            continue;
          }
          let ifat = ifi.addrs().unwrap();
          for ifa in &ifat {
            if ifa.addr() == remote {
              ti.teardown().unwrap();
              panic!("got {ifa:?}");
            }
          }
        }
      }
      Err(e) => {
        ti.teardown().unwrap();
        panic!("{}", e);
      }
    }

    ti.teardown().unwrap();
    std::thread::sleep(Duration::from_millis(3));
  }
}

// Same NetBSD platform-quirk as `point_to_point_interface`: the TUN
// interface this test brings up exposes a non-canonical netmask
// through the routing socket dump. Skip on NetBSD.
#[cfg(all(unix, not(target_os = "netbsd")))]
#[test]
fn test_interface_arrival_and_departure() {
  if std::env::var("RUST_TEST_SHORT").is_ok() {
    return;
  }

  #[cfg(bsd_like)]
  let uid = unsafe { libc::getuid() };
  #[cfg(linux_like)]
  let uid = rustix::process::getuid().as_raw();
  if uid != 0 {
    return;
  }

  let local: IpAddr = "169.254.0.1".parse().unwrap();
  let remote: IpAddr = "169.254.0.254".parse().unwrap();
  let ip = remote;

  for vid in [1002, 1003, 1004, 1005].iter() {
    let ift1 = interfaces().unwrap();

    let mut ti = TestInterface::new(local, remote);

    if let Err(e) = ti.set_broadcast(*vid) {
      println!("test requires external command: {e}");
      return;
    }

    if let Err(e) = ti.setup() {
      // Always attempt to undo any partial setup. See
      // `point_to_point_interface` for the rationale.
      ti.try_teardown();
      let err_msg = e.to_string();
      // Same rationale as `point_to_point_interface`: BSD CI VMs often
      // lack the `if_vlan` kernel module, so the `ifconfig <vlan>
      // create` command exits non-zero. Skip rather than fail when the
      // diagnostic matches a recognised missing-module pattern.
      if is_environmental_skip(&err_msg) {
        println!(
          "skipping test; interface creation failed (likely missing kernel module): {err_msg}"
        );
        return;
      }
      panic!("{}", e);
    }
    thread::sleep(Duration::from_millis(3));

    let ift2 = match interfaces() {
      Ok(interfaces) => interfaces,
      Err(e) => {
        ti.teardown().unwrap();
        panic!("{}", e);
      }
    };

    // Check by name rather than total interface count. The previous
    // `ift2.len() > ift1.len()` form raced with any other test (or
    // any other process on the box) creating an unrelated interface
    // between the `ift1` snapshot and our setup — `cargo test` runs
    // tests in parallel by default, so the BSD CI VM hit this
    // routinely. Asserting "the specific name we created is now
    // present" is what we actually care about.
    let _ = ift1;
    if !ift2.iter().any(|ifi| ifi.name == ti.name) {
      for ifi in &ift2 {
        println!("after: {ifi:?}");
      }
      ti.teardown().unwrap();
      panic!("interface {} not present after setup", ti.name);
    }

    for ifi in ift2.iter() {
      if ti.name != ifi.name {
        continue;
      }

      let addrs = ifi.addrs().unwrap();
      for addr in addrs {
        if let IpAddr::V4(addr_ip) = addr.addr() {
          if ip == IpAddr::V4(addr_ip) {
            ti.teardown().unwrap();
            panic!("got {addr:?}");
          }
        }
      }
    }

    ti.teardown().unwrap();
    thread::sleep(Duration::from_millis(3));

    // Same name-based check on the post-teardown side: the kernel
    // can take a moment to actually drop a vlan/gif and another
    // test on the same VM may create an unrelated interface in the
    // gap, so the count alone isn't a reliable signal. We just need
    // to know our specific interface is gone.
    let ift3 = interfaces().unwrap();
    if ift3.iter().any(|ifi| ifi.name == ti.name) {
      for ifi in &ift3 {
        println!("after-teardown: {ifi:?}");
      }
      panic!("interface {} still present after teardown", ti.name);
    }
  }
}