Quickstart
Let's dive into iroh by building a simple peer-to-peer file transfer tool in rust!
What we'll build
At the end we should be able to transfer a file from one device by running this:
$ cargo run -- send ./file.txt
Indexing file.
File analyzed. Fetch this file by running:
cargo run -- receive blobabvojvy[...] file.txt
And then fetch it on any other device like so:
$ cargo run -- receive blobabvojvy[...] file.txt
Starting download.
Finished download.
Copying to destination.
Finished copying.
Shutting down.
In this guide we'll be omitting the import statements required to get this working. If you're ever confused about what to import, take a look at the imports in the complete example.
Get set up
We'll assume you've set up rust and cargo on your machine.
Initialize a new project by running cargo init file-transfer
, then cd file-transfer
and install all the packages we're going to use: cargo add iroh iroh-blobs iroh-base tokio anyhow
.
From here on we'll be working inside the src/main.rs
file.
Create an iroh::Endpoint
To start interacting with other iroh nodes, we need to build an iroh::Endpoint
.
This is what manages the possibly changing network underneath, maintains a connection to the closest relay, and finds ways to address devices by NodeId
.
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Create an endpoint, it allows creating and accepting
// connections in the iroh p2p world
let endpoint = Endpoint::builder().discovery_n0().bind().await?;
// ...
Ok(())
}
There we go, this is all we need to open connections or accept them.
Here, we're specifically configuring the Endpoint
's builder to include "number 0 discovery".
This makes it connect to DNS servers that number 0 runs to find which relay to talk to for specific NodeId
s.
It's a great default!
But if you want to, you can add other discovery types like discovery_local_network
based on mDNS, or discovery_dht
for discovery based on the bittorrent mainline DHT.
If all of this is too much magic for your taste, it's possible for the endpoint to work entirely without any discovery services.
In that case, you'll need to make sure you're not only dialing by NodeId
, but also help the Endpoint
out with giving it the whole NodeAddr
when connecting.
Using an existing protocol: iroh-blobs
Instead of writing our own protocol from scratch, let's use iroh-blobs, which already does what we want: It loads files from your file system and provides a protocol for seekable, resumable downloads of these files.
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Create an endpoint, it allows creating and accepting
// connections in the iroh p2p world
let endpoint = Endpoint::builder().discovery_n0().bind().await?;
// We initialize the Blobs protocol in-memory
let local_pool = LocalPool::default();
let blobs = Blobs::memory().build(&local_pool, &endpoint);
// ...
Ok(())
}
Learn more about what we mean by "protocol" on the protocol documentation page.
With these two lines, we've initialized iroh-blobs and gave it access to our Endpoint
.
This is not quite enough to make it answer requests from the network, for that we need to configure a so-called Router
for protocols.
Similar to routers in webserver libraries, it runs a loop accepting incoming connections and routes them to the specific handler.
However, instead of handlers being organized by HTTP paths, it routes based on "ALPNs".
Read more about ALPNs and the router on the protocol and router documentation pages.
Now, using the Router
we can finish the skeleton of our application integrating iroh and iroh-blobs:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Create an endpoint, it allows creating and accepting
// connections in the iroh p2p world
let endpoint = Endpoint::builder().discovery_n0().bind().await?;
// We initialize the Blobs protocol in-memory
let local_pool = LocalPool::default();
let blobs = Blobs::memory().build(&local_pool, &endpoint);
// Now we build a router that accepts blobs connections & routes them
// to the blobs protocol.
let router = Router::builder(endpoint)
.accept(iroh_blobs::ALPN, blobs.clone())
.spawn()
.await?;
// do *something*
// Gracefully shut down the router
println!("Shutting down.");
router.shutdown().await?;
local_pool.shutdown().await;
Ok(())
}
I've also taken the liberty to make sure that we're gracefully shutting down the Router
and all its protocols with it, as well as the LocalPool
that the iroh-blobs library needs to operate.
Doing something
So far, this code works, but doesn't actually do anything besides spinning up a node and immediately shutting down.
Even if we put in a tokio::time::timeout
or tokio::signal::ctrl_c().await
in there, it would actually respond to network requests for the blobs protocol, but even that is practically useless as we've stored no blobs to respond with.
Here's our plan for turning this into a CLI that actually does what we set out to build:
- We'll grab a
Blobs::client
to interact with the iroh-blobs node we're running locally. - We check the CLI arguments to find out whether you ran
cargo run -- send [PATH]
orcargo run -- receive [TICKET] [PATH]
. - If we're supposed to send data:
- we'll use
add_from_path
to index local data and make it available, - print instructions for fetching said file,
- and then wait for Ctrl+C.
- If we're supposed to receive data:
- we'll parse the ticket out of the CLI arguments,
- download the file using
download
, - and copy the result the local file system.
Phew okay! Here's how we'll grab an iroh-blobs client and look at the CLI arguments:
let blobs = blobs.client();
let args = std::env::args().collect::<Vec<_>>();
match &args.iter().map(String::as_str).collect::<Vec<_>>()[..] {
[_cmd, "send", path] => {
todo!();
}
[_cmd, "receive", ticket, path] => {
todo!();
}
_ => {
println!("Couldn't parse command line arguments.");
println!("Usage:");
println!(" # to send:");
println!(" cargo run --example transfer -- send [FILE]");
println!(" # this will print a ticket.");
println!();
println!(" # to receive:");
println!(" cargo run --example transfer -- receive [TICKET] [FILE]");
}
}
Now all we need to do is fill in the todo!()
s one-by-one:
Getting ready to send
If we want to make a file available over the network with iroh-blobs, we first need to index this file.
What does this step do?
It hashes the file using BLAKE3 and stores a so-called "outboard" for that file. This outboard file contains information about hashes for parts of this file. All of this enables some extra features with iroh-blobs like automatically verifying the integrity of the file during streaming, verified range downloads and download resumption.
let abs_path = PathBuf::from_str(path)?.canonicalize()?;
println!("Indexing file.");
let blob = blobs
.add_from_path(abs_path, true, SetTagOption::Auto, WrapOption::NoWrap)
.await?
.finish()
.await?;
The WrapOption::NoWrap
is just an indicator that we don't want to wrap the file with some metadata information about its file name.
We keep it simple here for now!
Now, we'll print a BlobTicket
.
This ticket contains the NodeId
of our Endpoint
as well as the file's BLAKE3 hash.
let node_id = router.endpoint().node_id();
let ticket = BlobTicket::new(node_id.into(), blob.hash, blob.format)?;
println!("File analyzed. Fetch this file by running:");
println!("cargo run --example transfer -- receive {ticket} {path}");
tokio::signal::ctrl_c().await?;
And as you can see, as a final step we wait for the user to stop the file providing side by hitting Ctrl+C
in the console.
Connecting to the other side to receive
On the connection side, we got the ticket
and the path
from the CLI arguments and we can parse them into their struct
versions.
With them parsed, we can call blobs.download
with the information contained in the ticket and wait for the download to finish:
let path_buf = PathBuf::from_str(path)?;
let ticket = BlobTicket::from_str(ticket)?;
println!("Starting download.");
blobs
.download(ticket.hash(), ticket.node_addr().clone())
.await?
.finish()
.await?;
println!("Finished download.");
As a final step, we'll copy the file we just downloaded to the desired file path:
println!("Copying to destination.");
let mut file = tokio::fs::File::create(path_buf).await?;
let mut reader = blobs.read_at(ticket.hash(), 0, ReadAtLen::All).await?;
tokio::io::copy(&mut reader, &mut file).await?;
println!("Finished copying.");
This first download the file completely into memory, then copy that memory into a file in two steps.
There's ways to make this work without having to store the whole file in memory, but that involves setting up Blobs::persistent
instead of Blobs::memory
and using blobs.export
with EntryMode::TryReference
.
We'll leave these changes as an exercise to the reader 😉
That's it!
You've now successfully built a small tool for peer-to-peer file transfers! 🎉
The full example with the very latest version of iroh and iroh-blobs can be viewed on github.
If you're hungry for more, check out
- the iroh rust documentation,
- other examples,
- other available protocols or
- a longer guide on how to write your own protocol.
If rust is not actually your jam, make sure to check out the language bindings!