Build a P2P Virtual Mesh Network in Rust

Build a TUN-based P2P tunnel in Rust that lets two peers ping each other.

Introduction

30 Second Summary

When two computers need to talk directly, firewalls and network address translators usually block the way. VPN tools like Tailscale and ZeroTier solve this by creating virtual networks that make remote machines feel like they're on the same local network.

In this project, you will build the same kind of tool from scratch in Rust. Your application creates a virtual network interface on each peer and forwards IP packets between them through an encrypted P2P tunnel.

What You'll Build

A command-line Rust application called meshnet that creates TUN virtual network devices on two peers and forwards IP packets between them over iroh's QUIC P2P transport.

By the end of this project, you'll have:

  • A Rust binary with listen and connect subcommands that create TUN virtual network devices with assigned virtual IP addresses.
  • An iroh P2P endpoint with automatic hole-punching and relay fallback for establishing connections through firewalls.
  • A bidirectional packet forwarding loop that bridges TUN devices and iroh QUIC streams using length-prefixed framing, letting two peers ping each other over virtual IPs.
  • Secret Mission: extend the tunnel into a multi-peer mesh network with IP-based routing so 3+ nodes can all communicate.

Are there any prerequisites?

This project assumes intermediate Rust experience, including comfort with async/await, ownership, and error handling. You also need a Linux environment with sudo access and basic networking knowledge (IP addresses, subnets, ping).

Before We Start

Before We Start...

Set Up the Rust Project

Every virtual mesh network works by bridging a virtual network device with a peer-to-peer connection. One machine reads IP packets from its virtual interface, forwards them to a remote peer, and the peer writes those packets into its own interface.

You'll build exactly that in this project. This first step creates the Rust project and configures the five crates that power everything.

In this step, get ready to:
  • Create a new Rust binary project called meshnet.
  • Configure Cargo.toml with all project dependencies.
  • Verify the project compiles with cargo build.
Create a new Rust binary project
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the meshnet project and move into it by running these commands:
cargo init meshnet
cd meshnet
  • Confirm the project was created by running:
ls

You should see a Cargo.toml file and a src/ directory in the output.

Seeing 'destination already exists'?

This means a folder named meshnet already exists on your Desktop. Delete the existing folder and try cargo init meshnet again.

Help me fix this cargo init error.

Add project dependencies

This project relies on five external crates. Each one handles a different layer of the tunnel you'll build.

  • Open Cargo.toml in your editor (it's in the meshnet folder you just created).
  • Replace the entire contents of the file with:
[package]
name = "meshnet"
version = "0.1.0"
edition = "2024"

[dependencies]
iroh = "0.98"
tun-rs = { version = "2.8", features = ["async"] }
tokio = { version = "1.52", features = ["full"] }
clap = { version = "4.6", features = ["derive"] }
n0-error = "0.1"

What do these dependencies do?

  • iroh provides the P2P QUIC transport. iroh handles connection establishment, hole-punching through NATs, and relay fallback automatically.
  • tun-rs creates TUN virtual network interfaces. The async feature enables Tokio-compatible async I/O on the device.
  • tokio is the async runtime that powers every async/await call in the project.
  • clap parses CLI arguments using derive macros, so you can define subcommands as Rust structs.
  • n0-error provides iroh-compatible error handling types and conversion traits.
  • Save Cargo.toml.

You won't see a visible change yet. The next substep compiles the project and confirms the dependencies are correct.

✔️ Awesome, I've got everything!

Double check you've saved Cargo.toml before moving on.

ⓧ I'd like to double check the full code

Here's the complete Cargo.toml for comparison.

[package]
name = "meshnet"
version = "0.1.0"
edition = "2024"

[dependencies]
iroh = "0.98"
tun-rs = { version = "2.8", features = ["async"] }
tokio = { version = "1.52", features = ["full"] }
clap = { version = "4.6", features = ["derive"] }
n0-error = "0.1"
Verify the project compiles

With the dependencies declared, Cargo needs to download and compile them all. The iroh crate has a large dependency tree, so this first build takes a few minutes.

  • Build the project by running:
cargo build

You should see a stream of Compiling lines as Cargo works through the dependency tree. When it finishes, the last line reads Finished.

Build failed?

  • If you see a version resolution error, make sure your Rust toolchain is 1.89 or newer. Run rustc --version to check, and rustup update to update.
  • If you see a TOML parse error, double check that every quote and bracket in Cargo.toml matches the code above exactly. Pay close attention to the features = ["async"] syntax.
  • If you see a network error, check your internet connection. Cargo needs to download crates from crates.io.

Help me debug this cargo build error.

Your project is scaffolded and every dependency is compiled. Next up, you'll define the CLI and create the TUN virtual network device that gives your process its own IP address.

Build the CLI and TUN Device

Your Rust project compiles and is ready for real code. The first thing your program needs is a way for the user to specify whether it should listen for incoming connections or connect to a remote peer.

In this step, you'll define the CLI using clap's derive macros, then build the function that creates a TUN virtual network device. TUN devices are what make overlay networks possible.

In this step, get ready to:
  • Define Listen and Connect CLI subcommands with clap.
  • Write a function that creates a TUN device with a virtual IP address.
  • Test TUN device creation by running the program with sudo.
Define the CLI with clap

Your meshnet tool needs two modes. In listen mode, a node waits for a peer to connect. In connect mode, a node dials a listener using its address ticket.

Clap's derive macros let you define these modes as Rust structs and enums. The compiler generates all the argument parsing for you.

  • Replace everything in src/main.rs with this code:
use std::net::Ipv4Addr;

use clap::{Parser, Subcommand};
use iroh::{Endpoint, EndpointAddr, endpoint::presets};
use n0_error::StdResultExt;
use tun_rs::DeviceBuilder;

const ALPN: &[u8] = b"meshnet/tunnel/0";

#[derive(Parser)]
#[command(name = "meshnet", about = "P2P virtual mesh network")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Start listening for a peer connection
    Listen {
        /// Virtual IP address for this node (e.g., 10.0.0.1)
        #[arg(long)]
        ip: Ipv4Addr,
        /// Subnet mask prefix length
        #[arg(long, default_value = "24")]
        mask: u8,
    },
    /// Connect to a listening peer
    Connect {
        /// Virtual IP address for this node (e.g., 10.0.0.2)
        #[arg(long)]
        ip: Ipv4Addr,
        /// Subnet mask prefix length
        #[arg(long, default_value = "24")]
        mask: u8,
        /// Remote peer's EndpointAddr ticket string
        addr: String,
    },
}

What does this code do?

Cli is the top-level parser struct. Clap reads it to generate --help output and validate arguments automatically.

Command defines two subcommands. Listen takes --ip for the node's virtual IP address. Connect takes --ip plus a positional addr argument for the remote peer's ticket string.

ALPN is a protocol identifier used during QUIC connection setup. iroh checks it to make sure both sides of a connection speak the same protocol.

The imports include some iroh types like Endpoint and EndpointAddr that you won't use until a later step. Rust will warn about them for now.

  • Save src/main.rs.
  • Verify the code compiles by running:
cargo build

You'll see warnings about unused imports and the unused ALPN constant. That's expected. Those are used in later steps.

Seeing a compile error?

  • Check that your Cargo.toml still has all five dependencies from Step 1. Missing clap with the derive feature is a common cause.
  • Make sure the use statements match exactly. iroh::{Endpoint, EndpointAddr, endpoint::presets} needs the nested path syntax with the lowercase endpoint module.
  • Verify your Cargo.toml has edition = "2024". The 2024 edition is required by iroh 0.98.
  • Help me fix this Rust compile error in my meshnet CLI code.
Write the TUN device function

A TUN device is a virtual network interface managed entirely in software. When you assign it an IP address, your operating system routes packets for that IP through the TUN device instead of a physical network card.

Your create_tun function creates one of these devices using tun-rs and configures it with the virtual IP from the CLI.

  • In src/main.rs, add this function below the Command enum's closing brace:
async fn create_tun(ip: Ipv4Addr, mask: u8) -> std::io::Result<tun_rs::AsyncDevice> {
    let dev = DeviceBuilder::new()
        .ipv4(ip, mask, None)
        .mtu(1400)
        .build_async()?;
    println!("Created TUN device: {:?}", dev.name());
    Ok(dev)
}

What does this code do?

  • DeviceBuilder::new() starts configuring a new TUN device.
  • .ipv4(ip, mask, None) assigns the virtual IP address with the subnet prefix. None means no specific destination address.
  • .mtu(1400) sets the maximum packet size to 1400 bytes. Standard Ethernet MTU is 1500, but your packets will be wrapped in QUIC/UDP/IP headers by iroh. The lower MTU leaves room for that overhead and prevents fragmentation.
  • .build_async() creates the device with async I/O support powered by tokio.
  • Save src/main.rs.

You won't see a change yet. The next substep wires this function into main() where you'll test it.

Wire up main() and test the TUN device

The last piece is a main() function that parses the CLI arguments, calls create_tun with the right IP, and reports success.

  • In src/main.rs, add this below the create_tun function:
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    match cli.command {
        Command::Listen { ip, mask } => {
            let tun_dev = create_tun(ip, mask).await?;
            println!("TUN device ready");
            Ok(())
        }
        Command::Connect { ip, mask, addr } => {
            let tun_dev = create_tun(ip, mask).await?;
            println!("TUN device ready");
            Ok(())
        }
    }
}

What does this code do?

  • #[tokio::main] turns main() into an async function running on the tokio runtime.
  • Cli::parse() reads and validates the command-line arguments against the struct you defined.
  • The match statement routes to different behavior depending on whether the user ran listen or connect. Both branches create a TUN device and exit for now.
  • Save src/main.rs.
  • Build the project by running:
cargo build

Creating virtual network interfaces requires root privileges. You need sudo to test the TUN device.

  • Run the listener with a virtual IP of 10.0.0.1 by running:
sudo ./target/debug/meshnet listen --ip 10.0.0.1

You should see output like Created TUN device: "tun0" followed by TUN device ready.

  • Open a second terminal and confirm the TUN interface exists by running:
ip addr show

Look for an interface named tun0 (or similar) with IP address 10.0.0.1/24 in the output.

Don't see the TUN device?

  • Make sure you used sudo. Without root privileges, Linux won't let you create TUN interfaces.
  • Check that the tun kernel module is loaded. Run lsmod | grep tun to verify. If it's missing, load it with sudo modprobe tun.
  • If you see Permission denied even with sudo, verify your user has sudo access by running sudo whoami. It should print root.
  • Help me troubleshoot why my TUN device isn't being created in my Rust meshnet project.

Why does the program exit immediately?

Right now both branches create the TUN device, print a message, and return. The TUN device is destroyed when the program exits because the OS cleans up the interface. In the next steps, you'll add iroh P2P endpoints and a packet forwarding loop that keeps the program running.

✔️ Awesome, I've got everything!

Double check you've saved src/main.rs.

ⓧ I'd like to double check the full code

Here is the complete src/main.rs at the end of this step.

use std::net::Ipv4Addr;

use clap::{Parser, Subcommand};
use iroh::{Endpoint, EndpointAddr, endpoint::presets};
use n0_error::StdResultExt;
use tun_rs::DeviceBuilder;

const ALPN: &[u8] = b"meshnet/tunnel/0";

#[derive(Parser)]
#[command(name = "meshnet", about = "P2P virtual mesh network")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Start listening for a peer connection
    Listen {
        /// Virtual IP address for this node (e.g., 10.0.0.1)
        #[arg(long)]
        ip: Ipv4Addr,
        /// Subnet mask prefix length
        #[arg(long, default_value = "24")]
        mask: u8,
    },
    /// Connect to a listening peer
    Connect {
        /// Virtual IP address for this node (e.g., 10.0.0.2)
        #[arg(long)]
        ip: Ipv4Addr,
        /// Subnet mask prefix length
        #[arg(long, default_value = "24")]
        mask: u8,
        /// Remote peer's EndpointAddr ticket string
        addr: String,
    },
}

async fn create_tun(ip: Ipv4Addr, mask: u8) -> std::io::Result<tun_rs::AsyncDevice> {
    let dev = DeviceBuilder::new()
        .ipv4(ip, mask, None)
        .mtu(1400)
        .build_async()?;
    println!("Created TUN device: {:?}", dev.name());
    Ok(dev)
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    match cli.command {
        Command::Listen { ip, mask } => {
            let tun_dev = create_tun(ip, mask).await?;
            println!("TUN device ready");
            Ok(())
        }
        Command::Connect { ip, mask, addr } => {
            let tun_dev = create_tun(ip, mask).await?;
            println!("TUN device ready");
            Ok(())
        }
    }
}

Your Cargo.toml should be unchanged from Step 1.

Your CLI parses arguments and your TUN device is live with a virtual IP. Next, you'll set up iroh P2P endpoints so two nodes can discover and connect to each other.

Set Up Iroh P2P Endpoints

Your meshnet app can create TUN devices, but right now each one is isolated. For two peers to exchange packets across the internet, you need a transport layer that handles connection establishment, encryption, and NAT traversal.

That's where iroh comes in. iroh gives you P2P QUIC transport with automatic hole-punching and relay fallback. In this step, you'll create iroh endpoints for both the listener and connector, then verify they can exchange a test message before wiring up real packet forwarding.

In this step, get ready to:
  • Create an iroh endpoint for the listener that prints a shareable address ticket.
  • Create an iroh endpoint for the connector that dials the listener.
  • Test the P2P connection with a bidirectional message exchange.
Write the listener endpoint function

The listener is the peer that waits for incoming connections. It needs to create an iroh Endpoint, register your custom ALPN protocol, and print out a ticket string that the connector can use to dial in.

  • In src/main.rs, find the create_tun function. Add the following run_listen function below it:
async fn run_listen(ip: Ipv4Addr, mask: u8) -> Result<(), Box<dyn std::error::Error>> {
    let tun_dev = create_tun(ip, mask).await?;

    // Create an iroh endpoint and register our custom protocol
    let endpoint = Endpoint::builder(presets::N0)
        .alpns(vec![ALPN.to_vec()])
        .bind()
        .await?;

    // Wait until connected to a relay server
    endpoint.online().await;
    println!("Listening. Share this address with your peer:");
    println!("{}", endpoint.addr());

    // Block until a peer connects
    let incoming = endpoint
        .accept()
        .await
        .ok_or("No incoming connection")?;
    let conn = incoming.await.anyerr()?;
    println!("Peer connected: {}", conn.remote_id());

    // Accept a bidirectional QUIC stream and send a test message
    let (mut send_stream, mut recv_stream) = conn.accept_bi().await.anyerr()?;
    send_stream.write_all(b"hello").await.anyerr()?;
    send_stream.finish().anyerr()?;

    // Read any response from the peer
    let mut buf = vec![0u8; 1024];
    if let Ok(Some(n)) = recv_stream.read(&mut buf).await.anyerr() {
        println!("Received: {}", String::from_utf8_lossy(&buf[..n]));
    }

    // Clean up the connection and endpoint
    conn.close(0u32.into(), b"done");
    endpoint.close().await;
    Ok(())
}

What does this code do?

Endpoint::builder(presets::N0) creates an iroh endpoint pre-configured to use n0's free public relay servers. These relays help peers find each other and punch through NATs. The .alpns() call registers your custom protocol identifier so iroh knows which incoming connections belong to your app.

endpoint.online().await blocks until the endpoint has connected to a relay server. Once online, endpoint.addr() returns an EndpointAddr ticket containing the endpoint's public key, relay URL, and any direct addresses. This ticket is everything the connector needs to dial in.

The accept_bi() call opens a bidirectional QUIC stream. For now, the listener sends a test "hello" message and reads a response. You'll replace this with real packet forwarding in the next step.

Why iroh instead of raw QUIC?

Raw QUIC libraries like quinn require you to handle certificate management, connection migration, NAT traversal, and relay fallback yourself. iroh wraps all of this into a simple "dial by public key" API.

For a project focused on bridging TUN devices with P2P transport, iroh's simplicity lets you focus on the interesting part: the packet forwarding loop.

  • Save src/main.rs.

You won't be able to test this function yet. The next substep adds the connector, and the substep after that wires up main() so both sides can run.

Write the connector endpoint function

The connector is the peer that initiates the connection. It takes the listener's printed ticket string, parses it into an EndpointAddr, and dials the listener directly.

  • In src/main.rs, add the following run_connect function below run_listen:
async fn run_connect(
    ip: Ipv4Addr,
    mask: u8,
    addr_str: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let tun_dev = create_tun(ip, mask).await?;

    // Create an iroh endpoint (no ALPN needed for the connecting side)
    let endpoint = Endpoint::bind(presets::N0).await?;
    endpoint.online().await;

    // Parse the remote peer's ticket and connect
    let addr: EndpointAddr = addr_str.parse()?;
    println!("Connecting to peer...");
    let conn = endpoint.connect(addr, ALPN).await?;
    println!("Connected to: {}", conn.remote_id());

    // Open a bidirectional QUIC stream and read the test message
    let (mut send_stream, mut recv_stream) = conn.open_bi().await.anyerr()?;

    let mut buf = vec![0u8; 1024];
    if let Ok(Some(n)) = recv_stream.read(&mut buf).await.anyerr() {
        println!("Received: {}", String::from_utf8_lossy(&buf[..n]));
    }

    // Clean up the connection and endpoint
    conn.close(0u32.into(), b"done");
    endpoint.close().await;
    Ok(())
}

What does this code do?

The connector uses Endpoint::bind(presets::N0) instead of Endpoint::builder(). It doesn't need to register an ALPN because it's initiating the connection, not accepting one.

addr_str.parse() converts the ticket string back into an EndpointAddr containing the listener's public key and relay URL. Then endpoint.connect(addr, ALPN) dials the listener. iroh automatically tries direct connections first and falls back to relaying through n0's servers if hole-punching fails.

Notice the connector calls open_bi() while the listener calls accept_bi(). One side opens the bidirectional stream, the other accepts it. Both get a (SendStream, RecvStream) pair with the streams crossed: the connector's RecvStream receives what the listener's SendStream sends.

  • Save src/main.rs.

One more change before you can test. The main() function still has the old placeholder code from the previous step.

Wire up main and test the connection

Both endpoint functions are ready. Now you need to update main() to dispatch to them, then verify the P2P connection works across two terminals.

  • In src/main.rs, scroll down to the main() function.
  • Find the match block. It currently looks like this:
    match cli.command {
        Command::Listen { ip, mask } => {
            let tun_dev = create_tun(ip, mask).await?;
            println!("TUN device ready");
            Ok(())
        }
        Command::Connect { ip, mask, addr } => {
            let tun_dev = create_tun(ip, mask).await?;
            println!("TUN device ready");
            Ok(())
        }
    }
  • Replace the entire match block with the following, so it dispatches to your new functions:
    match cli.command {
        Command::Listen { ip, mask } => run_listen(ip, mask).await,
        Command::Connect { ip, mask, addr } => run_connect(ip, mask, &addr).await,
    }
  • Save src/main.rs.
  • Build the project by running:
cargo build

The build should complete with no errors. You may see a warning about unused tun_dev variables. That's expected. You'll use them in the next step.

Seeing a compilation error?

  • If you see cannot find function for run_listen or run_connect, make sure both functions are defined above main() in the file.
  • If you see mismatched types on the match arms, make sure both arms return the same type. Each function returns Result<(), Box<dyn std::error::Error>>.
  • If you see errors about .anyerr() not being found, confirm the use n0_error::StdResultExt; import is at the top of your file.

Help me debug my meshnet compilation error.

Now test the P2P connection across two terminals. You'll need both terminals open at the same time.

  • In your first terminal, start the listener by running:
sudo ./target/debug/meshnet listen --ip 10.0.0.1

After a moment, you should see a line that says Listening. Share this address with your peer: followed by a long ticket string. This string is the EndpointAddr containing the listener's public key and relay server URL.

  • Copy the entire ticket string.
  • Open a second terminal window.
  • Navigate to your meshnet project folder in the second terminal.
  • Run the connector, pasting the ticket string at the end:
sudo ./target/debug/meshnet connect --ip 10.0.0.2 [[ENDPOINT_ADDR="the ticket string you copied from terminal 1"]]

In the connector terminal, you should see Received: hello printed. This confirms that iroh successfully established a P2P connection and the bidirectional stream is working.

Connector hangs or times out?

  • Make sure you copied the complete ticket string. It's long and may wrap across multiple lines in your terminal. Missing even one character will cause a parse error.
  • Make sure the listener is still running in terminal 1 when you start the connector in terminal 2.
  • If your network blocks UDP traffic, iroh will fall back to relaying through n0's servers. This may take a few extra seconds but should still work.

Help me troubleshoot my iroh P2P connection.

✔️ Awesome, I've got everything!

Great. Double check you've saved src/main.rs.

ⓧ I'd like to double check the full code

Here's the complete src/main.rs at this point.

use std::net::Ipv4Addr;

use clap::{Parser, Subcommand};
use iroh::{Endpoint, EndpointAddr, endpoint::presets};
use n0_error::StdResultExt;
use tun_rs::DeviceBuilder;

const ALPN: &[u8] = b"meshnet/tunnel/0";

#[derive(Parser)]
#[command(name = "meshnet", about = "P2P virtual mesh network")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    Listen {
        #[arg(long)]
        ip: Ipv4Addr,
        #[arg(long, default_value = "24")]
        mask: u8,
    },
    Connect {
        #[arg(long)]
        ip: Ipv4Addr,
        #[arg(long, default_value = "24")]
        mask: u8,
        addr: String,
    },
}

async fn create_tun(ip: Ipv4Addr, mask: u8) -> std::io::Result<tun_rs::AsyncDevice> {
    let dev = DeviceBuilder::new()
        .ipv4(ip, mask, None)
        .mtu(1400)
        .build_async()?;
    println!("Created TUN device: {:?}", dev.name());
    Ok(dev)
}

async fn run_listen(ip: Ipv4Addr, mask: u8) -> Result<(), Box<dyn std::error::Error>> {
    let tun_dev = create_tun(ip, mask).await?;

    let endpoint = Endpoint::builder(presets::N0)
        .alpns(vec![ALPN.to_vec()])
        .bind()
        .await?;

    endpoint.online().await;
    println!("Listening. Share this address with your peer:");
    println!("{}", endpoint.addr());

    let incoming = endpoint
        .accept()
        .await
        .ok_or("No incoming connection")?;
    let conn = incoming.await.anyerr()?;
    println!("Peer connected: {}", conn.remote_id());

    let (mut send_stream, mut recv_stream) = conn.accept_bi().await.anyerr()?;
    send_stream.write_all(b"hello").await.anyerr()?;
    send_stream.finish().anyerr()?;

    let mut buf = vec![0u8; 1024];
    if let Ok(Some(n)) = recv_stream.read(&mut buf).await.anyerr() {
        println!("Received: {}", String::from_utf8_lossy(&buf[..n]));
    }

    conn.close(0u32.into(), b"done");
    endpoint.close().await;
    Ok(())
}

async fn run_connect(
    ip: Ipv4Addr,
    mask: u8,
    addr_str: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let tun_dev = create_tun(ip, mask).await?;

    let endpoint = Endpoint::bind(presets::N0).await?;
    endpoint.online().await;

    let addr: EndpointAddr = addr_str.parse()?;
    println!("Connecting to peer...");
    let conn = endpoint.connect(addr, ALPN).await?;
    println!("Connected to: {}", conn.remote_id());

    let (mut send_stream, mut recv_stream) = conn.open_bi().await.anyerr()?;

    let mut buf = vec![0u8; 1024];
    if let Ok(Some(n)) = recv_stream.read(&mut buf).await.anyerr() {
        println!("Received: {}", String::from_utf8_lossy(&buf[..n]));
    }

    conn.close(0u32.into(), b"done");
    endpoint.close().await;
    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    match cli.command {
        Command::Listen { ip, mask } => run_listen(ip, mask).await,
        Command::Connect { ip, mask, addr } => run_connect(ip, mask, &addr).await,
    }
}

Your two peers can talk to each other over iroh. The test message proves the transport layer works. Next up, you'll replace this test exchange with real packet forwarding that bridges the TUN device and the iroh stream.

Bridge TUN and Iroh with Packet Forwarding

Your P2P connection is working. The test message proved that iroh can establish a QUIC link between two peers, complete with hole-punching and relay fallback.

Now it's time to replace that test message with real packet forwarding. In this step, you'll build the bridge between the TUN device and the iroh stream so that actual IP packets flow between peers.

In this step, get ready to:
  • Write a forward_packets function with length-prefixed packet framing.
  • Update run_listen and run_connect to use the forwarding loop.
  • Test the full tunnel by pinging between two peers over virtual IPs.
Write the packet forwarding function

The TUN device produces raw IP packets, and the iroh stream is a byte stream. IP packets are variable length, so you need a way to mark where one packet ends and the next begins.

Length-prefixed framing solves this. Before sending each packet, you write a 2-byte header containing the packet's length. The receiver reads those 2 bytes first, then reads exactly that many bytes for the packet.

Why length-prefixed framing?

QUIC streams behave like TCP. They deliver an ordered byte stream with no built-in message boundaries.

Without framing, the receiver has no way to know where one IP packet ends and the next starts. A 2-byte big-endian u16 prefix supports packets up to 65,535 bytes, which is more than enough for the 1,400-byte MTU you configured on the TUN device.

  • In src/main.rs, find the create_tun function. Add the following forward_packets function directly below it (before run_listen):
async fn forward_packets(
    tun_dev: tun_rs::AsyncDevice,
    mut send_stream: iroh::endpoint::SendStream,
    mut recv_stream: iroh::endpoint::RecvStream,
) {
    // Clone the TUN device for the receive direction
    // (AsyncDevice uses Arc internally on Linux, so this is cheap)
    let tun_send = tun_dev.clone();

    tokio::select! {
        // Direction 1: TUN -> iroh (outbound packets)
        result = async {
            let mut buf = vec![0u8; 1500];
            loop {
                // Read a raw IP packet from the TUN device
                let n = tun_dev.recv(&mut buf).await?;
                if n == 0 {
                    continue;
                }
                // Length-prefix: write packet size as 2-byte big-endian u16
                let len = (n as u16).to_be_bytes();
                send_stream.write_all(&len).await.anyerr()?;
                // Write the actual packet data
                send_stream.write_all(&buf[..n]).await.anyerr()?;
            }
            #[allow(unreachable_code)]
            Ok::<(), n0_error::AnyError>(())
        } => {
            if let Err(e) = result {
                eprintln!("TUN -> iroh error: {e}");
            }
        }
        // Direction 2: iroh -> TUN (inbound packets)
        result = async {
            let mut len_buf = [0u8; 2];
            loop {
                // Read the 2-byte length prefix
                recv_stream.read_exact(&mut len_buf).await.anyerr()?;
                let len = u16::from_be_bytes(len_buf) as usize;
                // Read exactly that many bytes for the packet
                let mut pkt_buf = vec![0u8; len];
                recv_stream.read_exact(&mut pkt_buf).await.anyerr()?;
                // Write the packet into the TUN device
                tun_send.send(&pkt_buf).await?;
            }
            #[allow(unreachable_code)]
            Ok::<(), n0_error::AnyError>(())
        } => {
            if let Err(e) = result {
                eprintln!("iroh -> TUN error: {e}");
            }
        }
        // Direction 3: Ctrl+C handler for clean shutdown
        _ = tokio::signal::ctrl_c() => {
            println!("\nShutting down...");
        }
    }
}

What does this code do?

tokio::select! runs three branches concurrently. When any one completes (or errors), the others are automatically cancelled.

  • Branch 1 (TUN to iroh) reads IP packets from the TUN device, length-prefixes them, and writes them to the iroh SendStream.
  • Branch 2 (iroh to TUN) reads length-prefixed packets from the iroh RecvStream and writes them into the TUN device.
  • Branch 3 (Ctrl+C) listens for a Ctrl+C signal. When pressed, it prints a shutdown message and the select! cancels the forwarding loops.

The #[allow(unreachable_code)] annotations suppress compiler warnings. The Ok::<(), n0_error::AnyError>(()) return value is technically unreachable because the loops run forever, but Rust needs the type annotation to satisfy the async block's return type.

You won't see a change yet. The function isn't wired up to run_listen or run_connect. That's the next substep.

Wire up forwarding in both functions

Both run_listen and run_connect still have the test message exchange from the previous step. You need to replace that test code with a call to forward_packets.

  • In run_listen, find this block of test code after the Peer connected print:
    let (mut send_stream, mut recv_stream) = conn.accept_bi().await.anyerr()?;
    send_stream.write_all(b"hello").await.anyerr()?;
    send_stream.finish().anyerr()?;

    let mut buf = vec![0u8; 1024];
    if let Ok(Some(n)) = recv_stream.read(&mut buf).await.anyerr() {
        println!("Received: {}", String::from_utf8_lossy(&buf[..n]));
    }
  • Replace that entire block with this:
    // Accept the bidirectional stream from the connector
    let (send_stream, recv_stream) = conn.accept_bi().await.anyerr()?;

    // Bridge the TUN device and iroh stream
    forward_packets(tun_dev, send_stream, recv_stream).await;

What changed?

The mut keyword is removed from send_stream and recv_stream because forward_packets takes ownership and declares them as mut in its own signature. The entire test message exchange is gone, replaced by a single call that starts the bidirectional forwarding loop.

  • Now find run_connect. Locate this test code after the Connected to print:
    let (mut send_stream, mut recv_stream) = conn.open_bi().await.anyerr()?;

    let mut buf = vec![0u8; 1024];
    if let Ok(Some(n)) = recv_stream.read(&mut buf).await.anyerr() {
        println!("Received: {}", String::from_utf8_lossy(&buf[..n]));
    }
  • Replace it with this:
    // Open a bidirectional stream to the listener
    let (send_stream, recv_stream) = conn.open_bi().await.anyerr()?;

    // Bridge the TUN device and iroh stream
    forward_packets(tun_dev, send_stream, recv_stream).await;
  • Save src/main.rs.
  • Verify the project compiles by running:
cargo build

You should see a successful Compiling meshnet message with no errors.

Seeing a compile error?

  • If you see cannot find function `forward_packets`, make sure you placed the function above run_listen (between create_tun and run_listen).
  • If you see unused variable: tun_dev as an error (not just a warning), check that you removed the old test code and replaced it with the forward_packets(tun_dev, ...) call.
  • Check that you removed mut from the let (send_stream, recv_stream) bindings in both functions.

Help me debug my cargo build error for meshnet

Test the full P2P tunnel

The code is ready. Time to see IP packets flow between two peers over your virtual mesh network. You'll need three terminal windows for this test.

  • Build the project by running:
cargo build
  • In Terminal 1, start the listener with virtual IP 10.0.0.1 by running:
sudo ./target/debug/meshnet listen --ip 10.0.0.1

You should see the TUN device name printed (like tun0), followed by a Listening. Share this address with your peer: line and a long EndpointAddr string.

  • Copy the entire EndpointAddr string from Terminal 1.
  • In Terminal 2, start the connector with virtual IP 10.0.0.2 by running:
sudo ./target/debug/meshnet connect --ip 10.0.0.2 [[ENDPOINT_ADDR="the EndpointAddr string you copied from Terminal 1"]]

You should see Connecting to peer... followed by Connected to: with the listener's peer ID. Terminal 1 should also print Peer connected: with the connector's peer ID.

  • In Terminal 3, test the tunnel by pinging the connector's virtual IP:
ping 10.0.0.2

You should see ping replies coming back. Those ICMP packets are travelling from your kernel into the TUN device, getting length-prefixed and sent over the iroh QUIC stream to the remote peer, written into the remote TUN device, and bouncing back through the same path.

What is actually happening when you ping?

  • Your kernel sees that 10.0.0.2 belongs to the TUN device's subnet and routes the ICMP packet there.
  • The listener's forward_packets reads the packet from the TUN device, length-prefixes it, and writes it to the iroh send stream.
  • The connector's forward_packets reads the length-prefixed packet from the iroh recv stream and writes it into its own TUN device.
  • The connector's kernel receives the ICMP request and generates a reply, which flows back through the same path in reverse.

Not seeing ping replies?

  • Make sure both terminals show Peer connected before trying to ping. If the connection hasn't established, packets have nowhere to go.
  • Verify both commands were run with sudo. Without root, the TUN device creation fails silently or with a permission error.
  • Check that the virtual IPs are on the same subnet. The listener should use 10.0.0.1 and the connector should use 10.0.0.2 (both with the default /24 mask).
  • If you see TUN -> iroh error or iroh -> TUN error in either terminal, the connection may have dropped. Restart both sides and try again.

Help me debug my meshnet ping not working

  • Press Ctrl+C in both Terminal 1 and Terminal 2 to cleanly shut down the tunnel.

You should see Shutting down... printed in each terminal as the Ctrl+C handler triggers.

✔️ Awesome, I've got everything!

Make sure you've saved src/main.rs with all your changes.

ⓧ I'd like to double check the full code

Here is the complete src/main.rs at this point. Compare it against yours.

use std::net::Ipv4Addr;

use clap::{Parser, Subcommand};
use iroh::{Endpoint, EndpointAddr, endpoint::presets};
use n0_error::StdResultExt;
use tun_rs::DeviceBuilder;

const ALPN: &[u8] = b"meshnet/tunnel/0";

#[derive(Parser)]
#[command(name = "meshnet", about = "P2P virtual mesh network")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Start listening for a peer connection
    Listen {
        /// Virtual IP address for this node (e.g., 10.0.0.1)
        #[arg(long)]
        ip: Ipv4Addr,
        /// Subnet mask prefix length
        #[arg(long, default_value = "24")]
        mask: u8,
    },
    /// Connect to a listening peer
    Connect {
        /// Virtual IP address for this node (e.g., 10.0.0.2)
        #[arg(long)]
        ip: Ipv4Addr,
        /// Subnet mask prefix length
        #[arg(long, default_value = "24")]
        mask: u8,
        /// Remote peer's EndpointAddr ticket string
        addr: String,
    },
}

async fn create_tun(ip: Ipv4Addr, mask: u8) -> std::io::Result<tun_rs::AsyncDevice> {
    let dev = DeviceBuilder::new()
        .ipv4(ip, mask, None)
        .mtu(1400)
        .build_async()?;
    println!("Created TUN device: {:?}", dev.name());
    Ok(dev)
}

async fn forward_packets(
    tun_dev: tun_rs::AsyncDevice,
    mut send_stream: iroh::endpoint::SendStream,
    mut recv_stream: iroh::endpoint::RecvStream,
) {
    let tun_send = tun_dev.clone();

    tokio::select! {
        result = async {
            let mut buf = vec![0u8; 1500];
            loop {
                let n = tun_dev.recv(&mut buf).await?;
                if n == 0 {
                    continue;
                }
                let len = (n as u16).to_be_bytes();
                send_stream.write_all(&len).await.anyerr()?;
                send_stream.write_all(&buf[..n]).await.anyerr()?;
            }
            #[allow(unreachable_code)]
            Ok::<(), n0_error::AnyError>(())
        } => {
            if let Err(e) = result {
                eprintln!("TUN -> iroh error: {e}");
            }
        }
        result = async {
            let mut len_buf = [0u8; 2];
            loop {
                recv_stream.read_exact(&mut len_buf).await.anyerr()?;
                let len = u16::from_be_bytes(len_buf) as usize;
                let mut pkt_buf = vec![0u8; len];
                recv_stream.read_exact(&mut pkt_buf).await.anyerr()?;
                tun_send.send(&pkt_buf).await?;
            }
            #[allow(unreachable_code)]
            Ok::<(), n0_error::AnyError>(())
        } => {
            if let Err(e) = result {
                eprintln!("iroh -> TUN error: {e}");
            }
        }
        _ = tokio::signal::ctrl_c() => {
            println!("\nShutting down...");
        }
    }
}

async fn run_listen(ip: Ipv4Addr, mask: u8) -> Result<(), Box<dyn std::error::Error>> {
    let tun_dev = create_tun(ip, mask).await?;

    let endpoint = Endpoint::builder(presets::N0)
        .alpns(vec![ALPN.to_vec()])
        .bind()
        .await?;

    endpoint.online().await;
    println!("Listening. Share this address with your peer:");
    println!("{}", endpoint.addr());

    let incoming = endpoint
        .accept()
        .await
        .ok_or("No incoming connection")?;
    let conn = incoming.await.anyerr()?;
    println!("Peer connected: {}", conn.remote_id());

    let (send_stream, recv_stream) = conn.accept_bi().await.anyerr()?;

    forward_packets(tun_dev, send_stream, recv_stream).await;

    conn.close(0u32.into(), b"done");
    endpoint.close().await;
    Ok(())
}

async fn run_connect(
    ip: Ipv4Addr,
    mask: u8,
    addr_str: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let tun_dev = create_tun(ip, mask).await?;

    let endpoint = Endpoint::bind(presets::N0).await?;
    endpoint.online().await;

    let addr: EndpointAddr = addr_str.parse()?;
    println!("Connecting to peer...");
    let conn = endpoint.connect(addr, ALPN).await?;
    println!("Connected to: {}", conn.remote_id());

    let (send_stream, recv_stream) = conn.open_bi().await.anyerr()?;

    forward_packets(tun_dev, send_stream, recv_stream).await;

    conn.close(0u32.into(), b"done");
    endpoint.close().await;
    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    match cli.command {
        Command::Listen { ip, mask } => run_listen(ip, mask).await,
        Command::Connect { ip, mask, addr } => run_connect(ip, mask, &addr).await,
    }
}

You just built a working P2P virtual network tunnel. Two peers can now ping each other over virtual IPs as if they were on the same LAN. This is the same foundational pattern that tools like Tailscale, ZeroTier, and Nebula use under the hood.

Secret mission

Multi-Peer Mesh Routing

Right now your tunnel connects exactly two peers. Real mesh networks like Tailscale and ZeroTier let dozens of nodes talk to each other directly. Can you extend your tunnel into a true multi-peer mesh where three or more nodes route packets to the right destination?

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally and uses n0's free public relay servers, so there are no ongoing costs.

Resources you used:

  • The meshnet Rust project folder on your Desktop.
  • TUN virtual network devices (e.g., tun0, tun1) created during testing.
  • Compiled build artifacts in target/ (can be several hundred MB).

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend your mesh network with the Secret Mission.

  • Your meshnet project stays on your Desktop, ready to compile and run.
  • TUN devices are automatically destroyed when the meshnet process exits or you press Ctrl+C, so they don't persist between runs.

Pause - I'll come back to this later

Free up disk space from build artifacts while keeping your source code intact.

  • Stop any running meshnet processes by pressing Ctrl+C in each terminal.
  • Clean the build artifacts by running cargo clean inside the meshnet folder. This removes the target/ directory and frees several hundred MB of disk space.
  • When you come back, run cargo build to recompile. The first rebuild will take a few minutes as dependencies recompile.

Delete - I don't want to use this again

Remove all project files from your machine.

  • Stop any running meshnet processes by pressing Ctrl+C in each terminal.
  • Delete the entire meshnet project folder from your Desktop.
  • TUN devices are ephemeral and disappear automatically when the process stops. No manual cleanup needed.

Mission accomplished

That's a Wrap!

Nice work! You just built a P2P virtual mesh network in Rust. This is the same foundational architecture that powers tools like Tailscale, ZeroTier, and Nebula.

You've learned how to:

  • Created a TUN virtual network interface using tun-rs that gives your process a virtual IP address on a software-defined network.
  • Established P2P connectivity using iroh's QUIC transport with automatic hole-punching and relay fallback.
  • Built a bidirectional packet forwarding loop that bridges the TUN device and the iroh stream using length-prefixed framing, and verified it with a working ping between two peers over virtual IPs.
  • Secret Mission: Extended the tunnel into a multi-peer mesh with IP-based routing so 3+ nodes can all communicate.

Ready to quiz yourself?