Skip to content

How to find network interfaces in Rust with Tauri

🚀 Listing your network interfaces in Rust with pcap (Linux)

Section titled “🚀 Listing your network interfaces in Rust with pcap (Linux)”

When you start dabbling in network capture in Rust, the first step is simple: knowing which network interfaces are available on the machine. With the pcap crate, it takes only a few lines of code.

In this guide, we will see how to:

  • Install the required dependencies on Linux
  • Create a minimal Rust project
  • List the network interfaces and their addresses
  • Add a few simple unit tests

The pcap crate is a Rust binding to the libpcap library, widely used in C (by Wireshark, tcpdump…).

For everything to work, install the development headers:

Fenêtre de terminal
sudo apt update
sudo apt install libpcap-dev

⚠️ Note: Listing interfaces does not require sudo. But if you want to capture packets later, you will need to:

  • run your program with sudo, or
  • grant rights to your binary with setcap.

Fenêtre de terminal
cargo new pcap-list-interfaces
cd pcap-list-interfaces

Add the pcap crate to Cargo.toml:

[dependencies]
pcap = "0.10.0"
thiserror = "1"

Create src/main.rs:

use pcap::Device;
use thiserror::Error;
fn main() -> Result<(), PcapError> {
let interfaces = get_interfaces()?;
print_interfaces_names(interfaces.clone());
print_interfaces_addresses(interfaces);
Ok(())
}
#[derive(Debug, Error)]
pub enum PcapError {
#[error("Unable to list the network interfaces")]
DeviceListError(#[from] pcap::Error),
}
fn get_interfaces() -> Result<Vec<Device>, PcapError> {
let devices = Device::list()?;
Ok(devices)
}
fn print_interfaces_names(interfaces: Vec<Device>) {
for interface in interfaces {
println!("{}", interface.name);
}
}
fn print_interfaces_addresses(interfaces: Vec<Device>) {
for interface in interfaces {
for address in interface.addresses {
println!("{:?}", address.addr);
}
}
}

Even for a small utility, writing a few tests helps avoid nasty surprises:

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_interfaces_returns_ok() {
let res = get_interfaces();
assert!(res.is_ok(), "expected Ok, got {:?}", res);
}
#[test]
fn print_interfaces_names_does_not_panic() {
let interfaces = get_interfaces().unwrap_or_else(|_| Vec::new());
let outcome = std::panic::catch_unwind(|| {
print_interfaces_names(interfaces.clone());
});
assert!(outcome.is_ok(), "print_interfaces_names panicked");
}
#[test]
fn print_interfaces_addresses_does_not_panic() {
let interfaces = get_interfaces().unwrap_or_else(|_| Vec::new());
let outcome = std::panic::catch_unwind(|| {
print_interfaces_addresses(interfaces);
});
assert!(outcome.is_ok(), "print_interfaces_addresses panicked");
}
}

These tests ensure that:

  • get_interfaces works correctly
  • printing the names does not panic
  • printing the addresses does not panic

Fenêtre de terminal
cargo run

Sample output on a Linux machine:

Fenêtre de terminal
lo
eth0
wlan0
Some(192.168.1.42)
Some(127.0.0.1)
Some(::1)

Fenêtre de terminal
cargo test

Expected result:

Fenêtre de terminal
running 3 tests
test tests::get_interfaces_returns_ok ... ok
test tests::print_interfaces_names_does_not_panic ... ok
test tests::print_interfaces_addresses_does_not_panic ... ok

In fewer than 100 lines of Rust, we learned how to:

  • List a machine’s network interfaces on Linux
  • Display their IP addresses
  • Handle errors cleanly with thiserror
  • Write basic unit tests

🔭 Going further: integrating into Tauri

Section titled “🔭 Going further: integrating into Tauri”
Fenêtre de terminal
deno run -A npm:create-tauri-app

Sample configuration:

✔ Project name · get_net_interfaces
✔ Identifier · com.get_net_interfaces.app
✔ Frontend · Vue (TypeScript)
✔ Package manager · deno

Create a src-tauri/src/commandes/ folder and add:

use pcap::Device;
use tauri::command;
use thiserror::Error;
#[command]
pub fn get_net_interfaces() -> Result<Vec<String>, PcapError> {
let devices = get_interfaces()?;
Ok(devices.into_iter().map(|d| d.name).collect())
}
fn get_interfaces() -> Result<Vec<Device>, PcapError> {
let devices = Device::list()?;
Ok(devices)
}
#[derive(Debug, Error)]
pub enum PcapError {
#[error("Unable to list the network interfaces")]
DeviceListError(#[from] pcap::Error),
}
impl serde::Serialize for PcapError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
const devices = ref<string[]>([]);
async function getNetInterfaces() {
devices.value = await invoke("get_net_interfaces");
}
</script>
<template>
<main class="container">
<button @click="getNetInterfaces">Get Net Interfaces</button>
<ul>
<li v-for="device in devices" :key="device">{{ device }}</li>
</ul>
</main>
</template>

👉 Result: Your Tauri app can list the network interfaces on the Rust side with pcap, and return them to the Vue frontend through a #[command] command.

1) Why it breaks in Tauri: the orphan rule

Section titled “1) Why it breaks in Tauri: the orphan rule”

When a Tauri #[command] returns a value to the UI, Tauri serializes it (JSON) with Serde. If you return an external type like pcap::Device directly, there are two problems:

  1. The type is not serializable (no Serialize on pcap::Device).

  2. You cannot add Serialize to pcap::Device because Rust enforces the orphan rule:

    You cannot implement an external trait (here serde::Serialize) for an external type (here pcap::Device) from another crate.

In other words, what does not work:

// ❌ Forbidden by the orphan rule
impl serde::Serialize for pcap::Device {
fn serialize<S>(&self, _s: S) -> Result<S::Ok, S::Error>
where S: serde::Serializer
{ /* ... */ }
}

2) The robust solution: a DTO (Data Transfer Object)

Section titled “2) The robust solution: a DTO (Data Transfer Object)”

We create our own structures (which we own), derive Serialize on them, and add From<pcap::*> conversions to map the useful fields. Then the Tauri command returns our DTOs → serialization OK, UI happy.


3) Tauri v2 backend (Rust) — full DTO + command

Section titled “3) Tauri v2 backend (Rust) — full DTO + command”

src-tauri/Cargo.toml (excerpts):

[dependencies]
tauri = { version = "2", features = ["macros"] }
pcap = "2"
serde = { version = "1", features = ["derive"] }
thiserror = "1"

src-tauri/src/commandes/mod.rs:

use std::net::IpAddr;
use pcap::{
Address as PcapAddress,
ConnectionStatus as PcapConnectionStatus,
Device,
DeviceFlags as PcapDeviceFlags,
IfFlags as PcapIfFlags,
};
use serde::Serialize;
use tauri::command;
use thiserror::Error;
/// ===== Serializable DTOs for IPC =====
#[derive(Debug, Serialize)]
pub struct NetDevice {
pub name: String,
pub desc: Option<String>,
pub addresses: Vec<Address>,
pub flags: DeviceFlags,
}
#[derive(Debug, Serialize)]
pub struct Address {
pub addr: IpAddr,
pub netmask: Option<IpAddr>,
pub broadcast_addr: Option<IpAddr>,
pub dst_addr: Option<IpAddr>,
}
#[derive(Debug, Serialize)]
pub struct DeviceFlags {
pub if_flags: IfFlags,
pub connection_status: ConnectionStatus,
}
#[derive(Debug, Serialize)]
pub struct IfFlags {
/// Raw value (bitfield). Useful for decoding on the UI side or later.
pub bits: u32,
}
#[derive(Debug, Serialize)]
pub enum ConnectionStatus {
Unknown,
Connected,
Disconnected,
NotApplicable,
}
/// ===== pcap -> DTO conversions =====
impl From<PcapAddress> for Address {
fn from(a: PcapAddress) -> Self {
Address {
addr: a.addr,
netmask: a.netmask,
broadcast_addr: a.broadcast_addr,
dst_addr: a.dst_addr,
}
}
}
impl From<PcapIfFlags> for IfFlags {
fn from(f: PcapIfFlags) -> Self {
IfFlags { bits: f.bits() }
}
}
impl From<PcapConnectionStatus> for ConnectionStatus {
fn from(s: PcapConnectionStatus) -> Self {
match s {
PcapConnectionStatus::Unknown => ConnectionStatus::Unknown,
PcapConnectionStatus::Connected => ConnectionStatus::Connected,
PcapConnectionStatus::Disconnected => ConnectionStatus::Disconnected,
PcapConnectionStatus::NotApplicable => ConnectionStatus::NotApplicable,
}
}
}
impl From<PcapDeviceFlags> for DeviceFlags {
fn from(df: PcapDeviceFlags) -> Self {
DeviceFlags {
if_flags: df.if_flags.into(),
connection_status: df.connection_status.into(),
}
}
}
impl From<Device> for NetDevice {
fn from(d: Device) -> Self {
NetDevice {
name: d.name,
desc: d.desc,
addresses: d.addresses.into_iter().map(Address::from).collect(),
flags: d.flags.into(),
}
}
}
/// ===== Tauri command =====
#[command]
pub fn get_net_interfaces() -> Result<Vec<NetDevice>, PcapError> {
let devices = Device::list()?;
Ok(devices.into_iter().map(NetDevice::from).collect())
}
/// ===== Error handling =====
#[derive(Debug, Error)]
pub enum PcapError {
#[error("Unable to list the network interfaces")]
DeviceListError(#[from] pcap::Error),
}
// serialize the error as a String toward the UI
impl serde::Serialize for PcapError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}

src-tauri/src/lib.rs:

mod commandes;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() -> Result<(), tauri::Error> {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![commandes::get_net_interfaces])
.run(tauri::generate_context!())
}

4) Vue frontend (Options API) — select + detail (TypeScript)

Section titled “4) Vue frontend (Options API) — select + detail (TypeScript)”

src/types/NetDevice.ts:

export type Address = {
addr: string;
netmask?: string | null;
broadcast_addr?: string | null;
dst_addr?: string | null;
};
export type IfFlags = { bits: number };
export type ConnectionStatus =
| "Unknown"
| "Connected"
| "Disconnected"
| "NotApplicable";
export type DeviceFlags = {
if_flags: IfFlags;
connection_status: ConnectionStatus;
};
export type NetDevice = {
name: string;
desc?: string | null;
addresses: Address[];
flags: DeviceFlags;
};

src/components/NetDevicePicker.vue:

<script lang="ts">
import { defineComponent } from "vue";
import { invoke } from "@tauri-apps/api/core";
import type { NetDevice } from "../types/NetDevice";
export default defineComponent({
name: "NetDevicePicker",
data() {
return {
netDevices: [] as NetDevice[],
selectedName: "" as string,
loading: false,
errorMsg: null as string | null,
};
},
computed: {
selected(): NetDevice | undefined {
return this.netDevices.find((d) => d.name === this.selectedName);
},
hasDevices(): boolean {
return this.netDevices.length > 0;
},
},
mounted() {
this.refreshDevices();
},
methods: {
async refreshDevices() {
this.loading = true;
this.errorMsg = null;
try {
const list = await invoke<NetDevice[]>("get_net_interfaces");
this.netDevices = list;
if (
!this.selectedName ||
!this.netDevices.some((d) => d.name === this.selectedName)
) {
this.selectedName = this.netDevices[0]?.name ?? "";
}
} catch (e: unknown) {
this.errorMsg = (e as Error)?.message ?? String(e);
this.netDevices = [];
this.selectedName = "";
} finally {
this.loading = false;
}
},
},
});
</script>
<template>
<div class="picker">
<h2>Network interfaces</h2>
<div class="row">
<select v-model="selectedName" :disabled="loading || !hasDevices" @click="refreshDevices">
<option v-if="loading" disabled>Loading…</option>
<option v-else-if="!hasDevices" disabled>No interface</option>
<option v-for="dev in netDevices" :key="dev.name" :value="dev.name">
{{ dev.name }}{{ dev.desc ? ` — ${dev.desc}` : "" }}
</option>
</select>
<button @click="refreshDevices" :disabled="loading">🔄</button>
</div>
<p v-if="errorMsg" class="err">{{ errorMsg }}</p>
<div v-if="selected" class="card">
<h3>{{ selected.name }}</h3>
<p v-if="selected.desc" class="muted">{{ selected.desc }}</p>
<details v-if="selected.addresses?.length">
<summary>Addresses ({{ selected.addresses.length }})</summary>
<ul>
<li v-for="(a, i) in selected.addresses" :key="i">
{{ a.addr }}
<span v-if="a.netmask"> / {{ a.netmask }}</span>
<span v-if="a.broadcast_addr"> • bcast: {{ a.broadcast_addr }}</span>
<span v-if="a.dst_addr"> • dst: {{ a.dst_addr }}</span>
</li>
</ul>
</details>
<details>
<summary>Status & flags</summary>
<p>Connection: {{ selected.flags.connection_status }}</p>
<p>Flags (bits): {{ selected.flags.if_flags.bits }}</p>
</details>
</div>
</div>
</template>
<style scoped>
.picker { max-width: 720px; margin: 24px auto; }
.row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
select, button { padding: .55rem .75rem; border-radius: 8px; border: 1px solid #ddd; background: #fff; }
button:disabled, select:disabled { opacity: .6; cursor: not-allowed; }
.err { color: crimson; margin-top: 8px; }
.card { margin-top: 12px; padding: 12px; border: 1px solid #e3e3e3; border-radius: 10px; }
.muted { opacity: .7; }
</style>

5) Bonus: decoding IfFlags.bits into booleans (code pattern)

Section titled “5) Bonus: decoding IfFlags.bits into booleans (code pattern)”

You can expose readable flags on the UI side by adding a small backend helper. (Each environment/pcap may have different masks; keep the logic on the Rust side to stay portable.)

#[derive(Debug, Serialize)]
pub struct IfFlagsView {
pub bits: u32,
pub is_up: bool,
pub is_running: bool,
pub is_loopback: bool,
// add other derivations depending on your needs
}
impl From<PcapIfFlags> for IfFlagsView {
fn from(f: PcapIfFlags) -> Self {
let bits = f.bits();
// ⚠️ Generic example: replace MASK_* with the masks suited to your platform/pcap
const MASK_UP: u32 = 0x1;
const MASK_RUNNING: u32 = 0x40;
const MASK_LOOPBACK: u32 = 0x8;
Self {
bits,
is_up: (bits & MASK_UP) != 0,
is_running: (bits & MASK_RUNNING) != 0,
is_loopback: (bits & MASK_LOOPBACK) != 0,
}
}
}

Tip: start by exposing bits (as above), and only enable the booleans once you have verified the exact masks on your target (Linux, macOS, Windows/Npcap). You can log bits per interface to identify the flags present.


Fenêtre de terminal
deno task tauri dev

In summary:

  • The orphan rule prevents you from adding Serialize to pcap::Device.
  • The best practice: create a serializable DTO + From<pcap::*> conversions.
  • You keep a clean front-end API, stable and portable.